diff --git a/.gitignore b/.gitignore
index 538763b64b..efd2864137 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,8 @@
/app/*.log
/app/build
/app/release
+/wear/build
+/wear/release
/.idea/*
!/.idea/icon.svg
# Bundle
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ed2c6f41ff..ec2004a7be 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,11 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java
index 8865ae2a12..fbf2ff1bda 100644
--- a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java
+++ b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java
@@ -1,8 +1,10 @@
package protect.card_locker;
import android.app.Application;
+import android.content.Intent;
import androidx.appcompat.app.AppCompatDelegate;
+import androidx.core.content.ContextCompat;
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
@@ -11,6 +13,7 @@
import org.acra.data.StringFormat;
import protect.card_locker.preferences.Settings;
+import protect.card_locker.wearos.BluetoothServerService;
public class LoyaltyCardLockerApplication extends Application {
@@ -41,5 +44,13 @@ public void onCreate() {
// Set theme
Settings settings = new Settings(this);
AppCompatDelegate.setDefaultNightMode(settings.getTheme());
+
+ // Start Bluetooth server for Wear OS companion if enabled
+ if (settings.getWearSyncEnabled() &&
+ ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT)
+ == android.content.pm.PackageManager.PERMISSION_GRANTED
+ ) {
+ startService(new Intent(this, BluetoothServerService.class));
+ }
}
}
diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt
index d153823f59..0a1ad374d4 100644
--- a/app/src/main/java/protect/card_locker/MainActivity.kt
+++ b/app/src/main/java/protect/card_locker/MainActivity.kt
@@ -467,11 +467,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
ShortcutHelper.updateShortcuts(mAdapter.mContext)
}
- private fun processParseResultList(
- parseResultList: MutableList,
- group: String?,
- closeAppOnNoBarcode: Boolean
- ) {
+ private fun processParseResultList(parseResultList: MutableList) {
require(!parseResultList.isEmpty()) { "parseResultList may not be empty" }
Utils.makeUserChooseParseResultFromList(
@@ -482,17 +478,12 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
val intent =
Intent(applicationContext, LoyaltyCardEditActivity::class.java)
val bundle = parseResult.toLoyaltyCardBundle(this@MainActivity)
- if (group != null) {
- bundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, group)
- }
intent.putExtras(bundle)
startActivity(intent)
}
override fun onUserDismissedSelector() {
- if (closeAppOnNoBarcode) {
- finish()
- }
+ finish()
}
})
}
@@ -556,7 +547,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
return
}
- processParseResultList(parseResultList, null, true)
+ processParseResultList(parseResultList)
}
private fun extractIntentFields(intent: Intent) {
diff --git a/app/src/main/java/protect/card_locker/NotificationInfo.kt b/app/src/main/java/protect/card_locker/NotificationInfo.kt
new file mode 100644
index 0000000000..dae1d1434b
--- /dev/null
+++ b/app/src/main/java/protect/card_locker/NotificationInfo.kt
@@ -0,0 +1,8 @@
+package protect.card_locker
+
+object NotificationInfo {
+ object WearBluetooth {
+ const val NOTIFICATION_ID = 1001
+ const val CHANNEL_ID = "catima_wear_bt"
+ }
+}
diff --git a/app/src/main/java/protect/card_locker/preferences/Settings.java b/app/src/main/java/protect/card_locker/preferences/Settings.java
index f77f4b359f..39014cec3c 100644
--- a/app/src/main/java/protect/card_locker/preferences/Settings.java
+++ b/app/src/main/java/protect/card_locker/preferences/Settings.java
@@ -112,4 +112,8 @@ public int getPreferredColumnCount() {
public boolean useVolumeKeysForNavigation() {
return getBoolean(R.string.settings_key_use_volume_keys_navigation, false);
}
+
+ public boolean getWearSyncEnabled() {
+ return getBoolean(R.string.settings_key_wear_sync, false);
+ }
}
diff --git a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt
index 8d48320d22..dd5c72e2d7 100644
--- a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt
+++ b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt
@@ -1,22 +1,26 @@
package protect.card_locker.preferences
-import android.app.Activity
import android.content.Intent
+import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.ContextCompat
import androidx.core.os.LocaleListCompat
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreferenceCompat
import protect.card_locker.BuildConfig
import protect.card_locker.CatimaAppCompatActivity
import protect.card_locker.MainActivity
import protect.card_locker.R
import protect.card_locker.Utils
import protect.card_locker.databinding.SettingsActivityBinding
+import protect.card_locker.wearos.BluetoothServerService
class SettingsActivity : CatimaAppCompatActivity() {
@@ -81,6 +85,25 @@ class SettingsActivity : CatimaAppCompatActivity() {
class SettingsFragment : PreferenceFragmentCompat() {
var mReloadMain: Boolean = false
+ private val mBtPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ if (granted && Settings(requireContext()).wearSyncEnabled) {
+ requireContext().startService(Intent(requireContext(), BluetoothServerService::class.java))
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ Settings(requireContext()).wearSyncEnabled &&
+ ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ }
+
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.preferences)
@@ -104,7 +127,7 @@ class SettingsActivity : CatimaAppCompatActivity() {
val oledDarkPreference = findPreference(getString(R.string.settings_key_oled_dark))
oledDarkPreference!!.setOnPreferenceChangeListener { _, _ ->
- refreshActivity(true)
+ refreshActivity()
true
}
@@ -148,7 +171,7 @@ class SettingsActivity : CatimaAppCompatActivity() {
localePreference.setOnPreferenceChangeListener { _, newValue ->
// See corresponding comment in Utils.updateBaseContextLocale for Android 6- notes
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
- refreshActivity(true)
+ refreshActivity()
return@setOnPreferenceChangeListener true
}
val newLocale = newValue as String
@@ -157,13 +180,32 @@ class SettingsActivity : CatimaAppCompatActivity() {
true
}
+ val wearSyncPreference = findPreference(getString(R.string.settings_key_wear_sync))
+ wearSyncPreference!!.setOnPreferenceChangeListener { _, newValue ->
+ val enabled = newValue as Boolean
+ val ctx = requireContext()
+ if (enabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT)
+ } else {
+ ctx.startService(Intent(ctx, BluetoothServerService::class.java))
+ }
+ } else {
+ ctx.stopService(Intent(ctx, BluetoothServerService::class.java))
+ }
+ true
+ }
+
// Hide crash reporter settings on builds it's not enabled on
val crashReporterPreference = findPreference("acra.enable")
crashReporterPreference!!.isVisible = BuildConfig.useAcraCrashReporter
}
- private fun refreshActivity(reloadMain: Boolean) {
- mReloadMain = reloadMain || mReloadMain
+ private fun refreshActivity() {
+ mReloadMain = true
activity?.recreate()
}
}
diff --git a/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt
new file mode 100644
index 0000000000..4e843cba84
--- /dev/null
+++ b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt
@@ -0,0 +1,206 @@
+package protect.card_locker.wearos
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothServerSocket
+import android.bluetooth.BluetoothSocket
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import org.json.JSONArray
+import org.json.JSONObject
+import protect.card_locker.DBHelper
+import protect.card_locker.NotificationInfo
+import protect.card_locker.R
+import protect.card_locker.Utils
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.io.PrintWriter
+import java.util.UUID
+
+class BluetoothServerService : Service() {
+
+ companion object {
+ private const val TAG = "CatimaBtServer"
+ private const val BT_SERVICE_NAME = "CatimaWear"
+ val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90")
+ private const val PROTOCOL_VERSION = 1
+ private const val CMD_V1_CARDS_REQUEST = "V1/CARDS_REQUEST"
+ private const val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID
+ private const val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID
+ }
+
+ private var serverThread: AcceptThread? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ Log.w(TAG, "BLUETOOTH_CONNECT permission not granted, stopping")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ val adapter = (getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+ if (adapter == null || !adapter.isEnabled) {
+ Log.w(TAG, "Bluetooth not available or disabled")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ startForegroundWithNotification()
+ serverThread?.cancel()
+ serverThread = AcceptThread(adapter).also { it.start() }
+ Log.d(TAG, "Bluetooth server started")
+ return START_STICKY
+ }
+
+ private fun startForegroundWithNotification() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.wear_bt_channel_name),
+ NotificationManager.IMPORTANCE_MIN
+ ).apply { setShowBadge(false) }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.wear_bt_notification_title))
+ .setSmallIcon(R.drawable.ic_launcher_monochrome)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setSilent(true)
+ .build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(NOTIFICATION_ID, notification,
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ override fun onDestroy() {
+ serverThread?.cancel()
+ serverThread = null
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private inner class AcceptThread(adapter: BluetoothAdapter) : Thread() {
+ private var serverSocket: BluetoothServerSocket? = null
+ @Volatile private var running = true
+
+ init {
+ try {
+ if (ContextCompat.checkSelfPermission(
+ this@BluetoothServerService,
+ android.Manifest.permission.BLUETOOTH_CONNECT
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ serverSocket = adapter.listenUsingRfcommWithServiceRecord(BT_SERVICE_NAME, BT_SERVICE_UUID)
+ } else {
+ Log.w(TAG, "BLUETOOTH_CONNECT permission missing, cannot open server socket")
+ running = false
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create server socket", e)
+ running = false
+ }
+ }
+
+ override fun run() {
+ Log.d(TAG, "Listening for Bluetooth connections")
+ while (running) {
+ val socket: BluetoothSocket = try {
+ serverSocket?.accept() ?: break
+ } catch (e: Exception) {
+ if (running) Log.e(TAG, "Accept failed", e)
+ break
+ }
+ handleConnection(socket)
+ }
+ Log.d(TAG, "Accept loop ended")
+ if (running) {
+ Log.w(TAG, "Accept loop exited unexpectedly, restarting service")
+ stopSelf()
+ }
+ }
+
+ private fun handleConnection(socket: BluetoothSocket) {
+ val deviceName = try { socket.remoteDevice.name } catch (_: SecurityException) { "unknown" }
+ Log.d(TAG, "Connected to $deviceName")
+ try {
+ val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
+ val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
+ val command = reader.readLine()?.trim()
+ Log.d(TAG, "Received command: $command from $deviceName")
+ if (command != null && command.startsWith("V1/")) {
+ when (command) {
+ CMD_V1_CARDS_REQUEST -> {
+ val json = buildCardsJson()
+ writer.println(json)
+ writer.flush()
+ Log.d(TAG, "Sent ${json.length} bytes to $deviceName")
+ }
+ else -> Log.w(TAG, "Unsupported V1 command: $command from $deviceName")
+ }
+ } else {
+ Log.w(TAG, "Unsupported protocol version for command: $command from $deviceName")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling connection from $deviceName", e)
+ } finally {
+ try { socket.close() } catch (_: Exception) { }
+ }
+ }
+
+ private fun buildCardsJson(): String {
+ return JSONObject().apply {
+ put("version", PROTOCOL_VERSION)
+ put("cards", buildCardsArray())
+ }.toString()
+ }
+
+ private fun buildCardsArray(): JSONArray {
+ val dbHelper = DBHelper(this@BluetoothServerService)
+ val db = dbHelper.readableDatabase
+ val order = Utils.getLoyaltyCardOrder(this@BluetoothServerService)
+ val orderDirection = Utils.getLoyaltyCardOrderDirection(this@BluetoothServerService)
+ val cursor = DBHelper.getLoyaltyCardCursor(
+ db, "", null, order, orderDirection,
+ DBHelper.LoyaltyCardArchiveFilter.Unarchived
+ )
+ val array = JSONArray()
+ cursor.use {
+ val idIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID)
+ val storeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE)
+ val cardIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.CARD_ID)
+ val barcodeIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_ID)
+ val barcodeTypeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE)
+ val headerColorIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.HEADER_COLOR)
+ while (it.moveToNext()) {
+ array.put(JSONObject().apply {
+ put("id", it.getInt(idIdx))
+ put("store", it.getString(storeIdx) ?: "")
+ put("cardId", it.getString(cardIdIdx) ?: "")
+ put("barcodeId", if (it.isNull(barcodeIdIdx)) JSONObject.NULL else it.getString(barcodeIdIdx))
+ put("barcodeType", if (it.isNull(barcodeTypeIdx)) JSONObject.NULL else it.getString(barcodeTypeIdx))
+ put("headerColor", if (it.isNull(headerColorIdx)) JSONObject.NULL else it.getInt(headerColorIdx))
+ })
+ }
+ }
+ return array
+ }
+
+ fun cancel() {
+ running = false
+ try { serverSocket?.close() } catch (_: Exception) { }
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3cbdea0793..1d17ab4895 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -302,6 +302,10 @@
pref_column_count_landscape
General
Privacy
+ Wear OS
+ pref_wear_sync
+ Sync with Wear OS
+ Allow a Wear OS companion app to read your cards over Bluetooth
Display options
Show archived cards
View online
@@ -351,4 +355,6 @@
NFC is paused
Change settings
Failed to pause NFC
+ Wear OS companion
+ Catima watch sync active
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index fec2bce50f..37d407c492 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -134,4 +134,18 @@
app:singleLineTitle="false" />
+
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index e6d03252e1..929fcc46bd 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,7 +2,9 @@
plugins {
alias(libs.plugins.com.android.application) apply false
+ alias(libs.plugins.com.android.library) apply false
alias(libs.plugins.org.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.org.jetbrains.kotlin.plugin.compose) apply false
}
allprojects {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3d1841f2ad..d746279437 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,6 +9,9 @@ acra = "5.13.1"
# Testing
androidXTest = "1.7.0"
+# Wear OS
+wearCompose = "1.6.2"
+
[libraries]
# AndroidX
androidx-appcompat-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" }
@@ -42,6 +45,12 @@ net-lingala-zip4j-zip4j = { group = "net.lingala.zip4j", name = "zip4j", version
ch-acra-acra-mail = { group = "ch.acra", name = "acra-mail", version.ref = "acra" }
ch-acra-acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = "acra" }
+# Wear OS
+androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
+androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
+androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
+
+
# Testing
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidXTest" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTest" }
@@ -54,6 +63,7 @@ org-robolectric-robolectric = { group = "org.robolectric", name = "robolectric",
[plugins]
com-android-application = { id = "com.android.application", version = "9.2.1" }
+com-android-library = { id = "com.android.library", version = "9.2.1" }
org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
org-jetbrains-kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0ce82c4000..aba0d3ef15 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,3 +21,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Catima"
include(":app")
+include(":wear")
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
new file mode 100644
index 0000000000..378f2121b2
--- /dev/null
+++ b/wear/build.gradle.kts
@@ -0,0 +1,58 @@
+plugins {
+ alias(libs.plugins.com.android.application)
+ alias(libs.plugins.org.jetbrains.kotlin.android)
+ alias(libs.plugins.org.jetbrains.kotlin.plugin.compose)
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+android {
+ namespace = "me.hackerchick.catima.wear"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "me.hackerchick.catima.wear"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+
+ debug {
+ applicationIdSuffix = ".debug"
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.core.ktx)
+ implementation(libs.androidx.activity.activity.compose)
+
+ // Wear OS Compose
+ implementation(libs.androidx.wear.compose.material)
+ implementation(libs.androidx.wear.compose.foundation)
+ implementation(libs.androidx.wear.compose.navigation)
+
+ // ZXing for barcode rendering
+ implementation(libs.com.google.zxing.core)
+}
diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro
new file mode 100644
index 0000000000..da1f1d46e9
--- /dev/null
+++ b/wear/proguard-rules.pro
@@ -0,0 +1 @@
+-keep class me.hackerchick.catima.wear.** { *; }
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..5e10a1782f
--- /dev/null
+++ b/wear/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt
new file mode 100644
index 0000000000..3284772268
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt
@@ -0,0 +1,116 @@
+package me.hackerchick.catima.wear
+
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothSocket
+import android.content.Context
+import android.content.pm.PackageManager
+import android.util.Log
+import androidx.core.content.ContextCompat
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.io.PrintWriter
+
+object BluetoothCardClient {
+
+ private const val TAG = "CatimaBtClient"
+
+ enum class FetchStatus {
+ SUCCESS,
+ PHONE_OUTDATED,
+ WATCH_OUTDATED,
+ NO_DEVICE,
+ PERMISSION_DENIED,
+ BLUETOOTH_DISABLED
+ }
+
+ fun fetchCards(context: Context, onResult: (cards: String?, status: FetchStatus) -> Unit) {
+ if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ Log.w(TAG, "BLUETOOTH_CONNECT permission not granted")
+ onResult(null, FetchStatus.PERMISSION_DENIED)
+ return
+ }
+
+ val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+ if (adapter == null || !adapter.isEnabled) {
+ Log.w(TAG, "Bluetooth not available or disabled")
+ onResult(null, FetchStatus.BLUETOOTH_DISABLED)
+ return
+ }
+
+ val bondedDevices = adapter.bondedDevices
+ if (bondedDevices.isEmpty()) {
+ Log.w(TAG, "No bonded devices found")
+ onResult(null, FetchStatus.NO_DEVICE)
+ return
+ }
+
+ Log.d(TAG, "Trying ${bondedDevices.size} bonded device(s)")
+
+ Thread {
+ var result: String? = null
+ var status = FetchStatus.NO_DEVICE
+ for (device in bondedDevices) {
+ val deviceName = try { device.name } catch (_: Exception) { "unknown" }
+ Log.d(TAG, "Trying $deviceName")
+ var socket: BluetoothSocket? = null
+ try {
+ socket = device.createRfcommSocketToServiceRecord(WearProtocol.BT_SERVICE_UUID)
+ socket.connect()
+ Log.d(TAG, "Connected to $deviceName")
+
+ val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
+ val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
+
+ writer.print(WearProtocol.BT_CMD_CARDS_REQUEST_V1)
+ writer.flush()
+
+ val json = reader.readLine()?.trim() ?: ""
+ if (json.isNotEmpty()) {
+ Log.d(TAG, "Received ${json.length} bytes from $deviceName")
+ val parsed = parseResponse(json)
+ result = parsed.first
+ status = parsed.second
+ break
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "Failed to connect to $deviceName: ${e.message}")
+ } finally {
+ try { socket?.close() } catch (_: Exception) { }
+ }
+ }
+
+ if (status == FetchStatus.NO_DEVICE) {
+ Log.w(TAG, "No Catima phone found among bonded devices")
+ }
+ onResult(result, status)
+ }.start()
+ }
+
+ private fun parseResponse(json: String): Pair {
+ return try {
+ val obj = JSONObject(json)
+ val version = obj.getInt("version")
+ if (version != WearProtocol.PROTOCOL_VERSION) {
+ Log.w(TAG, "Unsupported protocol version: $version")
+ null to FetchStatus.WATCH_OUTDATED
+ } else {
+ val cards = obj.getJSONArray("cards").toString()
+ cards to FetchStatus.SUCCESS
+ }
+ } catch (_: Exception) {
+ try {
+ JSONArray(json)
+ Log.w(TAG, "Phone app returned a legacy response; update Catima on your phone")
+ null to FetchStatus.PHONE_OUTDATED
+ } catch (_: Exception) {
+ Log.w(TAG, "Unparseable response from phone")
+ null to FetchStatus.NO_DEVICE
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt
new file mode 100644
index 0000000000..244638325b
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt
@@ -0,0 +1,141 @@
+package me.hackerchick.catima.wear
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import androidx.wear.compose.navigation.SwipeDismissableNavHost
+import androidx.wear.compose.navigation.composable
+import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
+import me.hackerchick.catima.wear.ui.CardListScreen
+import me.hackerchick.catima.wear.ui.CardViewScreen
+import me.hackerchick.catima.wear.ui.theme.CatimaWearTheme
+
+class MainActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "CatimaWear"
+ }
+
+ @Volatile private var fetchInFlight = false
+
+ private val btPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) {
+ requestCardsFromPhone()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ WearCardRepository.loadCache(this)
+
+ setContent {
+ CatimaWearTheme {
+ val navController = rememberSwipeDismissableNavController()
+ val cards by WearCardRepository.cards.collectAsState()
+ val syncing by WearCardRepository.syncing.collectAsState()
+ val phoneNotReachable by WearCardRepository.phoneNotReachable.collectAsState()
+ val phoneOutdated by WearCardRepository.phoneOutdated.collectAsState()
+ val watchOutdated by WearCardRepository.watchOutdated.collectAsState()
+ val permissionDenied by WearCardRepository.permissionDenied.collectAsState()
+ val bluetoothDisabled by WearCardRepository.bluetoothDisabled.collectAsState()
+ val syncError by WearCardRepository.syncError.collectAsState()
+
+ SwipeDismissableNavHost(
+ navController = navController,
+ startDestination = "card_list"
+ ) {
+ composable("card_list") {
+ CardListScreen(
+ cards = cards,
+ syncing = syncing,
+ phoneNotReachable = phoneNotReachable,
+ phoneOutdated = phoneOutdated,
+ watchOutdated = watchOutdated,
+ permissionDenied = permissionDenied,
+ bluetoothDisabled = bluetoothDisabled,
+ syncError = syncError,
+ onCardClick = { card ->
+ navController.navigate("card_view/${card.id}")
+ }
+ )
+ }
+ composable(
+ route = "card_view/{cardId}",
+ arguments = listOf(navArgument("cardId") { type = NavType.IntType })
+ ) { backStackEntry ->
+ val cardId = backStackEntry.arguments?.getInt("cardId") ?: return@composable
+ val card = cards?.find { it.id == cardId }
+ CardViewScreen(card = card)
+ }
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ maybeRequestCards()
+ }
+
+ private fun maybeRequestCards() {
+ if (fetchInFlight) return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
+ } else {
+ requestCardsFromPhone()
+ }
+ }
+
+ private fun requestCardsFromPhone() {
+ fetchInFlight = true
+ WearCardRepository.setSyncing(true)
+ BluetoothCardClient.fetchCards(this) { json, status ->
+ fetchInFlight = false
+ when (status) {
+ BluetoothCardClient.FetchStatus.SUCCESS -> {
+ if (json != null) {
+ Log.d(TAG, "Got cards via Bluetooth")
+ WearCardRepository.updateCards(this, json)
+ } else {
+ WearCardRepository.setPhoneNotReachable()
+ }
+ }
+ BluetoothCardClient.FetchStatus.PHONE_OUTDATED -> {
+ Log.w(TAG, "Phone app is outdated")
+ WearCardRepository.setPhoneOutdated()
+ }
+ BluetoothCardClient.FetchStatus.WATCH_OUTDATED -> {
+ Log.w(TAG, "Wear app is outdated")
+ WearCardRepository.setWatchOutdated()
+ }
+ BluetoothCardClient.FetchStatus.NO_DEVICE -> {
+ Log.w(TAG, "Bluetooth failed, phone not reachable")
+ WearCardRepository.setPhoneNotReachable()
+ }
+ BluetoothCardClient.FetchStatus.PERMISSION_DENIED -> {
+ Log.w(TAG, "Bluetooth permission denied")
+ WearCardRepository.setPermissionDenied()
+ }
+ BluetoothCardClient.FetchStatus.BLUETOOTH_DISABLED -> {
+ Log.w(TAG, "Bluetooth is disabled")
+ WearCardRepository.setBluetoothDisabled()
+ }
+ }
+ }
+ }
+
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt
new file mode 100644
index 0000000000..d60de54ce0
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt
@@ -0,0 +1,44 @@
+package me.hackerchick.catima.wear
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class WearCard(
+ val id: Int,
+ val store: String,
+ val cardId: String,
+ val barcodeId: String?,
+ val barcodeType: String?,
+ val headerColor: Int?,
+) {
+ fun toJson(): JSONObject = JSONObject().apply {
+ put("id", id)
+ put("store", store)
+ put("cardId", cardId)
+ put("barcodeId", barcodeId ?: JSONObject.NULL)
+ put("barcodeType", barcodeType ?: JSONObject.NULL)
+ put("headerColor", headerColor ?: JSONObject.NULL)
+ }
+
+ companion object {
+ fun fromJson(json: JSONObject): WearCard = WearCard(
+ id = json.getInt("id"),
+ store = json.getString("store"),
+ cardId = json.getString("cardId"),
+ barcodeId = if (json.isNull("barcodeId")) null else json.optString("barcodeId").takeIf { it.isNotEmpty() },
+ barcodeType = if (json.isNull("barcodeType")) null else json.optString("barcodeType").takeIf { it.isNotEmpty() },
+ headerColor = if (json.isNull("headerColor")) null else json.getInt("headerColor"),
+ )
+
+ fun listToJson(cards: List): String {
+ val array = JSONArray()
+ cards.forEach { array.put(it.toJson()) }
+ return array.toString()
+ }
+
+ fun listFromJson(json: String): List {
+ val array = JSONArray(json)
+ return (0 until array.length()).map { fromJson(array.getJSONObject(it)) }
+ }
+ }
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt
new file mode 100644
index 0000000000..6e560048c6
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt
@@ -0,0 +1,119 @@
+package me.hackerchick.catima.wear
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+object WearCardRepository {
+ private const val TAG = "WearCardRepository"
+
+ private val _cards = MutableStateFlow?>(null)
+ val cards: StateFlow?> = _cards.asStateFlow()
+
+ private val _syncing = MutableStateFlow(false)
+ val syncing: StateFlow = _syncing.asStateFlow()
+
+ private val _phoneNotReachable = MutableStateFlow(false)
+ val phoneNotReachable: StateFlow = _phoneNotReachable.asStateFlow()
+
+ private val _phoneOutdated = MutableStateFlow(false)
+ val phoneOutdated: StateFlow = _phoneOutdated.asStateFlow()
+
+ private val _watchOutdated = MutableStateFlow(false)
+ val watchOutdated: StateFlow = _watchOutdated.asStateFlow()
+
+ private val _permissionDenied = MutableStateFlow(false)
+ val permissionDenied: StateFlow = _permissionDenied.asStateFlow()
+
+ private val _bluetoothDisabled = MutableStateFlow(false)
+ val bluetoothDisabled: StateFlow = _bluetoothDisabled.asStateFlow()
+
+ private val _syncError = MutableStateFlow(false)
+ val syncError: StateFlow = _syncError.asStateFlow()
+
+ fun loadCache(context: Context) {
+ val cached = WearCardStore.load(context)
+ if (cached != null && _cards.value == null) {
+ _cards.value = cached
+ Log.d(TAG, "Loaded ${cached.size} cards from cache")
+ }
+ }
+
+ fun setSyncing(value: Boolean) {
+ _syncing.value = value
+ if (value) {
+ _phoneNotReachable.value = false
+ _phoneOutdated.value = false
+ _watchOutdated.value = false
+ _permissionDenied.value = false
+ _bluetoothDisabled.value = false
+ _syncError.value = false
+ }
+ }
+
+ fun setPhoneNotReachable() {
+ _syncing.value = false
+ _phoneNotReachable.value = true
+ _phoneOutdated.value = false
+ _watchOutdated.value = false
+ _permissionDenied.value = false
+ _bluetoothDisabled.value = false
+ }
+
+ fun setPhoneOutdated() {
+ _syncing.value = false
+ _phoneNotReachable.value = false
+ _phoneOutdated.value = true
+ _watchOutdated.value = false
+ _permissionDenied.value = false
+ _bluetoothDisabled.value = false
+ }
+
+ fun setWatchOutdated() {
+ _syncing.value = false
+ _phoneNotReachable.value = false
+ _phoneOutdated.value = false
+ _watchOutdated.value = true
+ _permissionDenied.value = false
+ _bluetoothDisabled.value = false
+ }
+
+ fun setPermissionDenied() {
+ _syncing.value = false
+ _phoneNotReachable.value = false
+ _phoneOutdated.value = false
+ _watchOutdated.value = false
+ _permissionDenied.value = true
+ _bluetoothDisabled.value = false
+ }
+
+ fun setBluetoothDisabled() {
+ _syncing.value = false
+ _phoneNotReachable.value = false
+ _phoneOutdated.value = false
+ _watchOutdated.value = false
+ _permissionDenied.value = false
+ _bluetoothDisabled.value = true
+ }
+
+ fun updateCards(context: Context, json: String) {
+ _syncing.value = false
+ _phoneNotReachable.value = false
+ _phoneOutdated.value = false
+ _watchOutdated.value = false
+ _permissionDenied.value = false
+ _bluetoothDisabled.value = false
+ try {
+ val incoming = WearCard.listFromJson(json)
+ _cards.value = incoming
+ _syncError.value = false
+ WearCardStore.save(context, incoming)
+ Log.d(TAG, "Cards updated: ${incoming.size} cards")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse cards JSON", e)
+ _syncError.value = true
+ }
+ }
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt
new file mode 100644
index 0000000000..1be564a45b
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt
@@ -0,0 +1,23 @@
+package me.hackerchick.catima.wear
+
+import android.content.Context
+import androidx.core.content.edit
+
+object WearCardStore {
+ private const val PREFS_NAME = "catima_wear_cards"
+ private const val KEY_CARDS_JSON = "cards_json"
+
+ fun load(context: Context): List? {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val json = prefs.getString(KEY_CARDS_JSON, null) ?: return null
+ return runCatching { WearCard.listFromJson(json) }.getOrNull()
+ }
+
+ fun save(context: Context, cards: List) {
+ val json = WearCard.listToJson(cards)
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit {
+ putString(KEY_CARDS_JSON, json)
+ }
+ }
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt
new file mode 100644
index 0000000000..40a15fa2b5
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt
@@ -0,0 +1,9 @@
+package me.hackerchick.catima.wear
+
+import java.util.UUID
+
+object WearProtocol {
+ val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90")
+ const val PROTOCOL_VERSION = 1
+ const val BT_CMD_CARDS_REQUEST_V1 = "V1/CARDS_REQUEST\n"
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt
new file mode 100644
index 0000000000..a4696fee85
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt
@@ -0,0 +1,196 @@
+package me.hackerchick.catima.wear.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.items
+import androidx.wear.compose.material.Chip
+import androidx.wear.compose.material.ChipDefaults
+import androidx.wear.compose.material.CircularProgressIndicator
+import androidx.wear.compose.material.Text
+import me.hackerchick.catima.wear.R
+import me.hackerchick.catima.wear.WearCard
+
+@Composable
+fun CardListScreen(
+ cards: List?,
+ syncing: Boolean,
+ phoneNotReachable: Boolean,
+ phoneOutdated: Boolean,
+ watchOutdated: Boolean,
+ permissionDenied: Boolean,
+ bluetoothDisabled: Boolean,
+ syncError: Boolean,
+ onCardClick: (WearCard) -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ contentAlignment = Alignment.Center,
+ ) {
+ when {
+ cards == null && syncError -> {
+ Text(
+ text = stringResource(R.string.sync_error),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null && permissionDenied -> {
+ Text(
+ text = stringResource(R.string.bluetooth_permission_denied),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null && bluetoothDisabled -> {
+ Text(
+ text = stringResource(R.string.bluetooth_disabled),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null && phoneNotReachable -> {
+ Text(
+ text = stringResource(R.string.phone_not_connected),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null && phoneOutdated -> {
+ Text(
+ text = stringResource(R.string.phone_outdated),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null && watchOutdated -> {
+ Text(
+ text = stringResource(R.string.watch_outdated),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null -> {
+ CircularProgressIndicator()
+ }
+ cards.isEmpty() && !syncing -> {
+ Text(
+ text = stringResource(R.string.no_cards),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ else -> {
+ ScalingLazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(cards) { card ->
+ Chip(
+ modifier = Modifier.fillMaxWidth(),
+ label = {
+ Text(
+ text = card.store,
+ maxLines = 1,
+ )
+ },
+ onClick = { onCardClick(card) },
+ colors = if (card.headerColor != null) {
+ ChipDefaults.chipColors(backgroundColor = Color(card.headerColor))
+ } else {
+ ChipDefaults.primaryChipColors()
+ },
+ )
+ }
+ if (syncing) {
+ item {
+ Text(
+ text = stringResource(R.string.syncing),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ } else if (permissionDenied) {
+ item {
+ Text(
+ text = stringResource(R.string.bluetooth_permission_denied),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ } else if (bluetoothDisabled) {
+ item {
+ Text(
+ text = stringResource(R.string.bluetooth_disabled),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ } else if (phoneNotReachable) {
+ item {
+ Text(
+ text = stringResource(R.string.sync_failed),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ } else if (phoneOutdated) {
+ item {
+ Text(
+ text = stringResource(R.string.phone_outdated),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ } else if (syncError) {
+ item {
+ Text(
+ text = stringResource(R.string.sync_error),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ } else if (watchOutdated) {
+ item {
+ Text(
+ text = stringResource(R.string.watch_outdated),
+ textAlign = TextAlign.Center,
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt
new file mode 100644
index 0000000000..630c67b7ef
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt
@@ -0,0 +1,172 @@
+package me.hackerchick.catima.wear.ui
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.view.WindowManager
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.activity.compose.LocalActivity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.Text
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.MultiFormatWriter
+import me.hackerchick.catima.wear.R
+import me.hackerchick.catima.wear.WearCard
+
+@Composable
+fun CardViewScreen(card: WearCard?) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(androidx.compose.ui.graphics.Color.Black),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (card == null) {
+ androidx.wear.compose.material.CircularProgressIndicator()
+ } else {
+ CardDetail(card = card)
+ }
+ }
+}
+
+@Composable
+private fun KeepScreenOnAtMaxBrightness() {
+ val activity = LocalActivity.current ?: return
+ DisposableEffect(Unit) {
+ val window = activity.window
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ val originalBrightness = window.attributes.screenBrightness
+ val params = window.attributes
+ params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
+ window.attributes = params
+ onDispose {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ val restore = window.attributes
+ restore.screenBrightness = originalBrightness
+ window.attributes = restore
+ }
+ }
+}
+
+@Composable
+private fun CardDetail(card: WearCard) {
+ val barcodeValue = card.barcodeId ?: card.cardId
+ val barcodeType = card.barcodeType
+
+ KeepScreenOnAtMaxBrightness()
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(androidx.compose.ui.graphics.Color.White)
+ .padding(8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ val screenSize = minOf(maxWidth, maxHeight)
+ val roundInset = screenSize * 0.15f
+ val barcodeMaxSize = screenSize - roundInset * 2
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = card.store,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ color = androidx.compose.ui.graphics.Color.Black,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (barcodeType != null) {
+ val barcodeResult = remember(barcodeValue, barcodeType) {
+ generateBarcode(barcodeValue, barcodeType)
+ }
+
+ if (barcodeResult != null) {
+ Image(
+ bitmap = barcodeResult.bitmap.asImageBitmap(),
+ contentDescription = card.cardId,
+ modifier = if (barcodeResult.isSquare) {
+ Modifier.size(barcodeMaxSize)
+ } else {
+ Modifier.fillMaxWidth(0.85f).height(60.dp)
+ },
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.no_barcode),
+ textAlign = TextAlign.Center,
+ color = androidx.compose.ui.graphics.Color.Black,
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.no_barcode),
+ textAlign = TextAlign.Center,
+ color = androidx.compose.ui.graphics.Color.Black,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = barcodeValue,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ color = androidx.compose.ui.graphics.Color.Black,
+ )
+ }
+ }
+}
+
+private data class BarcodeResult(val bitmap: Bitmap, val isSquare: Boolean)
+
+private fun isBarcodeSquare(format: BarcodeFormat): Boolean =
+ format == BarcodeFormat.QR_CODE
+ || format == BarcodeFormat.AZTEC
+ || format == BarcodeFormat.DATA_MATRIX
+
+private fun generateBarcode(value: String, formatName: String): BarcodeResult? {
+ return try {
+ val format = BarcodeFormat.valueOf(formatName)
+ val isSquare = isBarcodeSquare(format)
+
+ val width = if (isSquare) 300 else 600
+ val height = if (isSquare) 300 else 150
+
+ val hints = mapOf(EncodeHintType.MARGIN to 0)
+ val bitMatrix = MultiFormatWriter().encode(value, format, width, height, hints)
+
+ val pixels = IntArray(width * height) { index ->
+ val x = index % width
+ val y = index / width
+ if (bitMatrix[x, y]) Color.BLACK else Color.WHITE
+ }
+
+ BarcodeResult(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888), isSquare)
+ } catch (_: Exception) {
+ null
+ }
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt
new file mode 100644
index 0000000000..f22e99abd8
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt
@@ -0,0 +1,9 @@
+package me.hackerchick.catima.wear.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material.MaterialTheme
+
+@Composable
+fun CatimaWearTheme(content: @Composable () -> Unit) {
+ MaterialTheme(content = content)
+}
diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..1123a86f4f
--- /dev/null
+++ b/wear/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..f4b23782b3
--- /dev/null
+++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..d378acd7ac
--- /dev/null
+++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..3d7b397c0f
--- /dev/null
+++ b/wear/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Catima
+ No cards found.\nOpen Catima on your phone to add cards.
+ Could not reach Catima on your phone.\nMake sure the app is installed and your watch is connected.
+ Loading cards…
+ No barcode available for this card.
+ Card ID
+ Syncing with phone…
+ Offline – showing cached cards
+ Catima on your phone is outdated.\nPlease update it.
+ This watch app is outdated.\nPlease update it.
+ Bluetooth permission denied.\nGrant it in Settings to sync cards.
+ Bluetooth is turned off.\nEnable it to sync cards from your phone.
+ Failed to read cards from your phone.\nTry syncing again.
+