diff --git a/.github/workflows/wear.yml b/.github/workflows/wear.yml
new file mode 100644
index 000000000..0dcac44b7
--- /dev/null
+++ b/.github/workflows/wear.yml
@@ -0,0 +1,46 @@
+name: Android Wear CI
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ - staging
+ - trying
+ pull_request:
+ branches:
+ - main
+permissions:
+ actions: none
+ checks: none
+ contents: read
+ deployments: none
+ discussions: none
+ id-token: none
+ issues: none
+ packages: none
+ pages: none
+ pull-requests: none
+ repository-projects: none
+ security-events: none
+ statuses: none
+env:
+ JAVA_HOME: /usr/lib/jvm/java-25-openjdk-amd64
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7.0.0
+ - uses: gradle/actions/wrapper-validation@v6.2.0
+ - name: set up OpenJDK 25
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y openjdk-25-jdk-headless
+ sudo update-alternatives --auto java
+ - name: Build
+ run: ./gradlew :wear:assembleRelease
+ - name: Check for non-free libraries
+ run: |
+ wget -q https://codeberg.org/Katastima/apkscanner/releases/download/v0.0.7/apk-scanner_v0.0.7.jar
+ java -jar apk-scanner_v0.0.7.jar scan-apk 'wear/build/outputs/apk/release/wear-release-unsigned.apk'
+ - name: Check lint
+ run: ./gradlew :wear:lintRelease
diff --git a/.gitignore b/.gitignore
index 538763b64..5fa9b5986 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,8 @@
/app/*.log
/app/build
/app/release
+/wear/build
+/wear/release
/.idea/*
!/.idea/icon.svg
# Bundle
@@ -28,3 +30,4 @@
# Catima-specific
SHA256SUMS
+/shared/build
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f2fbc7939..3752a63d5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -113,6 +113,8 @@ android {
}
dependencies {
+ implementation(project(":shared"))
+
// AndroidX
implementation(libs.androidx.appcompat.appcompat)
implementation(libs.androidx.constraintlayout.constraintlayout)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cdda86115..2d552a0ed 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 8865ae2a1..0309c616d 100644
--- a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java
+++ b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java
@@ -1,9 +1,9 @@
package protect.card_locker;
import android.app.Application;
+import android.content.Intent;
import androidx.appcompat.app.AppCompatDelegate;
-
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
import org.acra.config.DialogConfigurationBuilder;
@@ -11,6 +11,7 @@ import org.acra.config.MailSenderConfigurationBuilder;
import org.acra.data.StringFormat;
import protect.card_locker.preferences.Settings;
+import protect.card_locker.wearos.WearSyncServiceManager;
public class LoyaltyCardLockerApplication extends Application {
@@ -41,5 +42,10 @@ public class LoyaltyCardLockerApplication extends Application {
// Set theme
Settings settings = new Settings(this);
AppCompatDelegate.setDefaultNightMode(settings.getTheme());
+
+ // Start Bluetooth server for Wear OS companion if enabled.
+ // The service checks BLUETOOTH_CONNECT itself and stops if the permission is missing.
+ // The permission is requested from the launcher Activity when the UI resumes.
+ WearSyncServiceManager.INSTANCE.synchronize(this, null);
}
}
diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt
index d153823f5..04692499a 100644
--- a/app/src/main/java/protect/card_locker/MainActivity.kt
+++ b/app/src/main/java/protect/card_locker/MainActivity.kt
@@ -37,6 +37,7 @@ import protect.card_locker.databinding.MainActivityBinding
import protect.card_locker.databinding.SortingOptionBinding
import protect.card_locker.preferences.Settings
import protect.card_locker.preferences.SettingsActivity
+import protect.card_locker.wearos.WearSyncPermissionRequester
import java.io.UnsupportedEncodingException
import java.util.concurrent.atomic.AtomicInteger
import androidx.core.content.edit
@@ -61,6 +62,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
private lateinit var mUpdateLoyaltyCardListRunnable: Runnable
private lateinit var mBarcodeScannerLauncher: ActivityResultLauncher
private lateinit var mSettingsLauncher: ActivityResultLauncher
+ private lateinit var mWearSyncPermissionRequester: WearSyncPermissionRequester
private val mCurrentActionModeCallback: ActionMode.Callback = object : ActionMode.Callback {
override fun onCreateActionMode(inputMode: ActionMode, inputMenu: Menu?): Boolean {
@@ -318,6 +320,8 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
}
}
+ mWearSyncPermissionRequester = WearSyncPermissionRequester(this, this)
+
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (mSearchView != null && !mSearchView!!.isIconified) {
@@ -394,6 +398,8 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
val settings = Settings(this)
layoutManager.setSpanCount(settings.getPreferredColumnCount())
}
+
+ mWearSyncPermissionRequester.synchronize()
}
private fun displayCardSetupOptions(menu: Menu, shouldShow: Boolean) {
@@ -467,11 +473,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 +484,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 +553,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 000000000..dae1d1434
--- /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 f77f4b359..39014cec3 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 class Settings {
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 8d48320d2..9787f0424 100644
--- a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt
+++ b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt
@@ -1,9 +1,10 @@
package protect.card_locker.preferences
-import android.app.Activity
import android.content.Intent
+import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.provider.Settings
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatDelegate
@@ -11,12 +12,15 @@ import androidx.core.os.LocaleListCompat
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreferenceCompat
+import com.google.android.material.snackbar.Snackbar
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.WearSyncPermissionRequester
class SettingsActivity : CatimaAppCompatActivity() {
@@ -81,7 +85,16 @@ class SettingsActivity : CatimaAppCompatActivity() {
class SettingsFragment : PreferenceFragmentCompat() {
var mReloadMain: Boolean = false
+ private lateinit var wearSyncPermissionRequester: WearSyncPermissionRequester
+
+ override fun onResume() {
+ super.onResume()
+ wearSyncPermissionRequester.synchronize()
+ }
+
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ wearSyncPermissionRequester = WearSyncPermissionRequester(this, requireContext())
+
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.preferences)
@@ -104,7 +117,7 @@ class SettingsActivity : CatimaAppCompatActivity() {
val oledDarkPreference = findPreference(getString(R.string.settings_key_oled_dark))
oledDarkPreference!!.setOnPreferenceChangeListener { _, _ ->
- refreshActivity(true)
+ refreshActivity()
true
}
@@ -148,7 +161,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 +170,46 @@ class SettingsActivity : CatimaAppCompatActivity() {
true
}
+ val wearSyncPreference = findPreference(getString(R.string.settings_key_wear_sync))
+ wearSyncPreference!!.setOnPreferenceChangeListener { preference, newValue ->
+ val enabled = newValue as Boolean
+ if (enabled) {
+ wearSyncPermissionRequester.onWearSyncChanged(true) { granted ->
+ if (granted) {
+ (preference as? SwitchPreferenceCompat)?.isChecked = true
+ } else {
+ showWearSyncPermissionDeniedSnackbar()
+ }
+ }
+ false
+ } else {
+ wearSyncPermissionRequester.onWearSyncChanged(false)
+ 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 showWearSyncPermissionDeniedSnackbar() {
+ Snackbar.make(
+ requireView(),
+ R.string.wear_sync_permission_required,
+ Snackbar.LENGTH_LONG
+ ).setAction(R.string.open_settings) {
+ startActivity(
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", requireContext().packageName, null)
+ )
+ )
+ }.show()
+ }
+
+ 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 000000000..352600363
--- /dev/null
+++ b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt
@@ -0,0 +1,215 @@
+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.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+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 protect.card_locker.shared.BluetoothPermissionHelper
+import protect.card_locker.shared.WearBluetoothProtocol
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.io.PrintWriter
+
+class BluetoothServerService : Service() {
+
+ companion object {
+ private const val TAG = "CatimaBtServer"
+ 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 (!BluetoothPermissionHelper.isBluetoothConnectGranted(this)) {
+ 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 (BluetoothPermissionHelper.isBluetoothConnectGranted(this@BluetoothServerService)) {
+ serverSocket = adapter.listenUsingRfcommWithServiceRecord(
+ WearBluetoothProtocol.BT_SERVICE_NAME,
+ WearBluetoothProtocol.BT_SERVICE_UUID
+ )
+ } else {
+ Log.w(TAG, "BLUETOOTH_CONNECT permission missing, cannot open server socket")
+ running = false
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "BLUETOOTH_CONNECT permission missing, cannot create server socket", e)
+ 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 == WearBluetoothProtocol.BT_CMD_VERSIONS) {
+ val versions = JSONArray().put(WearBluetoothProtocol.PROTOCOL_VERSION).toString()
+ writer.println(versions)
+ writer.flush()
+ Log.d(TAG, "Sent supported versions to $deviceName")
+ } else if (command != null && command.startsWith("/V1/")) {
+ when {
+ command.startsWith(WearBluetoothProtocol.BT_CMD_CARDS_PAGE_PREFIX) -> {
+ val pageIndex = command.removePrefix(WearBluetoothProtocol.BT_CMD_CARDS_PAGE_PREFIX).toIntOrNull()
+ if (pageIndex == null || pageIndex < 0) {
+ Log.w(TAG, "Invalid page index in command: $command from $deviceName")
+ } else {
+ val json = buildCardsPageJson(pageIndex)
+ writer.println(json)
+ writer.flush()
+ Log.d(TAG, "Sent page $pageIndex (${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 buildCardsPageJson(pageIndex: Int): String {
+ 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 totalCards = cursor.count
+ val totalPages = if (totalCards == 0) 1 else (totalCards + WearBluetoothProtocol.PAGE_SIZE - 1) / WearBluetoothProtocol.PAGE_SIZE
+ val offset = pageIndex * WearBluetoothProtocol.PAGE_SIZE
+ val array = JSONArray()
+ cursor.use {
+ if (it.move(offset + 1)) {
+ 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)
+ var count = 0
+ do {
+ 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))
+ })
+ count++
+ } while (count < WearBluetoothProtocol.PAGE_SIZE && it.moveToNext())
+ }
+ }
+ return JSONObject().apply {
+ put("page", pageIndex)
+ put("totalPages", totalPages)
+ put("cards", array)
+ }.toString()
+ }
+
+ fun cancel() {
+ running = false
+ try { serverSocket?.close() } catch (_: Exception) { }
+ }
+ }
+}
diff --git a/app/src/main/java/protect/card_locker/wearos/WearSyncServiceManager.kt b/app/src/main/java/protect/card_locker/wearos/WearSyncServiceManager.kt
new file mode 100644
index 000000000..decf63f00
--- /dev/null
+++ b/app/src/main/java/protect/card_locker/wearos/WearSyncServiceManager.kt
@@ -0,0 +1,98 @@
+package protect.card_locker.wearos
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.ActivityResultCaller
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.edit
+import androidx.preference.PreferenceManager
+import protect.card_locker.R
+import protect.card_locker.preferences.Settings
+import protect.card_locker.shared.BluetoothPermissionHelper
+
+class WearSyncPermissionRequester(
+ caller: ActivityResultCaller,
+ context: Context
+) {
+ private val context = context.applicationContext
+ private var pendingResultCallback: ((Boolean) -> Unit)? = null
+
+ private val permissionLauncher = caller.registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ pendingResultCallback?.invoke(granted)
+ WearSyncServiceManager.onPermissionResult(this.context, granted)
+ pendingResultCallback = null
+ }
+
+ fun synchronize() {
+ WearSyncServiceManager.synchronize(context) {
+ permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ }
+
+ fun onWearSyncChanged(enabled: Boolean, onPermissionResult: ((Boolean) -> Unit)? = null) {
+ val applied = WearSyncServiceManager.onWearSyncChanged(context, enabled) {
+ pendingResultCallback = onPermissionResult
+ permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ if (applied) {
+ onPermissionResult?.invoke(true)
+ }
+ }
+}
+
+object WearSyncServiceManager {
+ fun synchronize(context: Context, requestPermission: (() -> Unit)? = null) {
+ if (!Settings(context).wearSyncEnabled) {
+ stop(context)
+ } else if (BluetoothPermissionHelper.isBluetoothConnectGranted(context)) {
+ start(context)
+ } else if (requestPermission != null) {
+ requestPermission()
+ } else {
+ stop(context)
+ }
+ }
+
+ fun onWearSyncChanged(
+ context: Context,
+ enabled: Boolean,
+ requestPermission: () -> Unit
+ ): Boolean {
+ return if (enabled) {
+ if (BluetoothPermissionHelper.isBluetoothConnectGranted(context)) {
+ start(context)
+ true
+ } else {
+ requestPermission()
+ false
+ }
+ } else {
+ stop(context)
+ true
+ }
+ }
+
+ fun onPermissionResult(context: Context, granted: Boolean) {
+ if (granted && Settings(context).wearSyncEnabled) {
+ start(context)
+ } else {
+ stop(context)
+ if (!granted) {
+ PreferenceManager.getDefaultSharedPreferences(context).edit {
+ putBoolean(context.getString(R.string.settings_key_wear_sync), false)
+ }
+ }
+ }
+ }
+
+ private fun start(context: Context) {
+ context.startService(Intent(context, BluetoothServerService::class.java))
+ }
+
+ private fun stop(context: Context) {
+ context.stopService(Intent(context, BluetoothServerService::class.java))
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 05d24b8d8..e7a1067c7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -304,6 +304,12 @@
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
+ Allow Catima access to nearby devices to sync with your smartwatch
+ Open settings
Display options
Show archived cards
View online
@@ -353,4 +359,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 fec2bce50..37d407c49 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/app/src/test/java/protect/card_locker/wearos/WearSyncServiceManagerTest.kt b/app/src/test/java/protect/card_locker/wearos/WearSyncServiceManagerTest.kt
new file mode 100644
index 000000000..a210690dd
--- /dev/null
+++ b/app/src/test/java/protect/card_locker/wearos/WearSyncServiceManagerTest.kt
@@ -0,0 +1,37 @@
+package protect.card_locker.wearos
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import protect.card_locker.R
+
+@RunWith(RobolectricTestRunner::class)
+class WearSyncServiceManagerTest {
+
+ @Test
+ fun onPermissionResult_denied_disablesWearSync() {
+ val context = ApplicationProvider.getApplicationContext()
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ prefs.edit().putBoolean(context.getString(R.string.settings_key_wear_sync), true).commit()
+
+ WearSyncServiceManager.onPermissionResult(context, false)
+
+ assertFalse(prefs.getBoolean(context.getString(R.string.settings_key_wear_sync), true))
+ }
+
+ @Test
+ fun onPermissionResult_granted_keepsWearSyncEnabled() {
+ val context = ApplicationProvider.getApplicationContext()
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ prefs.edit().putBoolean(context.getString(R.string.settings_key_wear_sync), true).commit()
+
+ WearSyncServiceManager.onPermissionResult(context, true)
+
+ assertTrue(prefs.getBoolean(context.getString(R.string.settings_key_wear_sync), false))
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index e6d03252e..929fcc46b 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 d6dcf1e35..710f1fd45 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" }
@@ -23,6 +26,7 @@ com-google-android-material-material = { group = "com.google.android.material",
com-android-tools-desugar_jdk_libs = { group = "com.android.tools", name = "desugar_jdk_libs", version = "2.1.5" }
# Compose
+androidx-activity-activity = { group = "androidx.activity", name = "activity", version = "1.13.0" }
androidx-activity-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.13.0" }
androidx-compose-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose" }
androidx-compose-foundation-foundation = { group = "androidx.compose.foundation", name = "foundation" }
@@ -42,6 +46,11 @@ 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.3.0" }
+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 0ce82c400..5fe9fde05 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,3 +21,5 @@ dependencyResolutionManagement {
}
rootProject.name = "Catima"
include(":app")
+include(":wear")
+include(":shared")
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
new file mode 100644
index 000000000..91652b645
--- /dev/null
+++ b/shared/build.gradle.kts
@@ -0,0 +1,29 @@
+plugins {
+ alias(libs.plugins.com.android.library)
+ alias(libs.plugins.org.jetbrains.kotlin.android)
+}
+
+android {
+ namespace = "protect.card_locker.shared"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 23
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
+ }
+ }
+}
+
+dependencies {
+ api(libs.androidx.activity.activity)
+ api(libs.androidx.core.core.ktx)
+}
diff --git a/shared/src/main/java/protect/card_locker/shared/BluetoothPermissionHelper.kt b/shared/src/main/java/protect/card_locker/shared/BluetoothPermissionHelper.kt
new file mode 100644
index 000000000..975de3f11
--- /dev/null
+++ b/shared/src/main/java/protect/card_locker/shared/BluetoothPermissionHelper.kt
@@ -0,0 +1,34 @@
+package protect.card_locker.shared
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.result.ActivityResultLauncher
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.core.content.ContextCompat
+
+object BluetoothPermissionHelper {
+ @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+ fun isBluetoothConnectRequired(): Boolean =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+ fun isBluetoothConnectGranted(context: Context): Boolean =
+ !isBluetoothConnectRequired() ||
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.BLUETOOTH_CONNECT
+ ) == PackageManager.PERMISSION_GRANTED
+
+ fun requestBluetoothConnectIfNeeded(
+ context: Context,
+ launcher: ActivityResultLauncher,
+ onGranted: () -> Unit
+ ) {
+ if (isBluetoothConnectGranted(context)) {
+ onGranted()
+ } else {
+ launcher.launch(Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ }
+}
diff --git a/shared/src/main/java/protect/card_locker/shared/WearBluetoothProtocol.kt b/shared/src/main/java/protect/card_locker/shared/WearBluetoothProtocol.kt
new file mode 100644
index 000000000..5e7ed88cf
--- /dev/null
+++ b/shared/src/main/java/protect/card_locker/shared/WearBluetoothProtocol.kt
@@ -0,0 +1,12 @@
+package protect.card_locker.shared
+
+import java.util.UUID
+
+object WearBluetoothProtocol {
+ val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90")
+ const val BT_SERVICE_NAME = "CatimaWear"
+ const val PROTOCOL_VERSION = 1
+ const val BT_CMD_VERSIONS = "/VERSIONS"
+ const val BT_CMD_CARDS_PAGE_PREFIX = "/V${PROTOCOL_VERSION}/CARDS_REQUEST_PAGE/"
+ const val PAGE_SIZE = 10
+}
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
new file mode 100644
index 000000000..4b6d29c83
--- /dev/null
+++ b/wear/build.gradle.kts
@@ -0,0 +1,55 @@
+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"
+ 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
+ }
+}
+
+dependencies {
+ implementation(project(":shared"))
+
+ 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 000000000..da1f1d46e
--- /dev/null
+++ b/wear/proguard-rules.pro
@@ -0,0 +1 @@
+-keep class me.hackerchick.catima.wear.** { *; }
diff --git a/wear/src/debug/res/values/strings.xml b/wear/src/debug/res/values/strings.xml
new file mode 100644
index 000000000..b3c239a65
--- /dev/null
+++ b/wear/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Catima Debug
+
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..6dc16b15f
--- /dev/null
+++ b/wear/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..997b29409
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt
@@ -0,0 +1,163 @@
+package me.hackerchick.catima.wear
+
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothSocket
+import android.content.Context
+import android.util.Log
+import org.json.JSONArray
+import org.json.JSONObject
+import protect.card_locker.shared.BluetoothPermissionHelper
+import protect.card_locker.shared.WearBluetoothProtocol
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.io.PrintWriter
+
+object BluetoothCardClient {
+
+ private const val TAG = "CatimaBtClient"
+
+ fun fetchCards(context: Context, onResult: (cards: String?, status: SyncStatus) -> Unit) {
+ if (!BluetoothPermissionHelper.isBluetoothConnectGranted(context)) {
+ Log.w(TAG, "BLUETOOTH_CONNECT permission not granted")
+ onResult(null, SyncStatus.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, SyncStatus.BLUETOOTH_DISABLED)
+ return
+ }
+
+ val bondedDevices = try {
+ adapter.bondedDevices
+ } catch (e: SecurityException) {
+ Log.w(TAG, "BLUETOOTH_CONNECT permission rejected", e)
+ emptySet()
+ }
+ if (bondedDevices.isEmpty()) {
+ Log.w(TAG, "No bonded devices found")
+ onResult(null, SyncStatus.PHONE_NOT_REACHABLE)
+ return
+ }
+
+ Log.d(TAG, "Trying ${bondedDevices.size} bonded device(s)")
+
+ Thread {
+ var result: String? = null
+ var status = SyncStatus.PHONE_NOT_REACHABLE
+ for (device in bondedDevices) {
+ val deviceName = try { device.name } catch (_: SecurityException) { "unknown" }
+ Log.d(TAG, "Trying $deviceName")
+ val fetchResult = fetchCardsFromDevice(device, deviceName)
+ if (fetchResult.second != SyncStatus.PHONE_NOT_REACHABLE) {
+ result = fetchResult.first
+ status = fetchResult.second
+ break
+ }
+ }
+
+ if (status == SyncStatus.PHONE_NOT_REACHABLE) {
+ Log.w(TAG, "Bluetooth sync failed, phone not reachable")
+ }
+ onResult(result, status)
+ }.start()
+ }
+
+ private fun fetchCardsFromDevice(
+ device: android.bluetooth.BluetoothDevice,
+ deviceName: String
+ ): Pair {
+ var socket: BluetoothSocket? = null
+ return try {
+ socket = device.createRfcommSocketToServiceRecord(WearBluetoothProtocol.BT_SERVICE_UUID)
+ socket.connect()
+ val supportedVersions = requestSupportedVersions(socket)
+ ?: return null to SyncStatus.PHONE_NOT_REACHABLE
+ if (WearBluetoothProtocol.PROTOCOL_VERSION !in supportedVersions) {
+ Log.w(TAG, "Phone does not support API version ${WearBluetoothProtocol.PROTOCOL_VERSION}")
+ return null to SyncStatus.WATCH_OUTDATED
+ }
+ socket.close()
+ socket = null
+ Log.d(TAG, "Connected to $deviceName with API version ${WearBluetoothProtocol.PROTOCOL_VERSION}")
+
+ val allCards = JSONArray()
+ var pageIndex = 0
+ var totalPages = 1
+
+ while (pageIndex < totalPages) {
+ socket = device.createRfcommSocketToServiceRecord(WearBluetoothProtocol.BT_SERVICE_UUID)
+ socket.connect()
+ val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
+ val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
+
+ writer.print("${WearBluetoothProtocol.BT_CMD_CARDS_PAGE_PREFIX}$pageIndex\n")
+ writer.flush()
+
+ val json = reader.readLine()?.trim() ?: ""
+ if (json.isEmpty()) {
+ Log.w(TAG, "Empty response for page $pageIndex from $deviceName")
+ return null to SyncStatus.PHONE_NOT_REACHABLE
+ }
+
+ val parsed = parsePageResponse(json, pageIndex)
+ if (parsed.second != SyncStatus.OK) {
+ return null to parsed.second
+ }
+ val pageCards = JSONArray(parsed.first!!)
+ for (i in 0 until pageCards.length()) {
+ allCards.put(pageCards.getJSONObject(i))
+ }
+ if (pageIndex == 0) {
+ totalPages = parsed.third
+ Log.d(TAG, "Total pages: $totalPages")
+ }
+ socket.close()
+ socket = null
+ pageIndex++
+ }
+
+ Log.d(TAG, "Fetched ${allCards.length()} cards in $totalPages page(s) from $deviceName")
+ allCards.toString() to SyncStatus.OK
+ } catch (e: Exception) {
+ Log.d(TAG, "Failed to connect to $deviceName: ${e.message}")
+ null to SyncStatus.PHONE_NOT_REACHABLE
+ } finally {
+ try { socket?.close() } catch (_: Exception) { }
+ }
+ }
+
+ private fun requestSupportedVersions(socket: BluetoothSocket): Set? {
+ val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
+ val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
+ writer.print("${WearBluetoothProtocol.BT_CMD_VERSIONS}\n")
+ writer.flush()
+ val response = reader.readLine()?.trim() ?: return null
+ return try {
+ val versions = JSONArray(response)
+ buildSet {
+ for (index in 0 until versions.length()) {
+ add(versions.getInt(index))
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Invalid versions response from phone", e)
+ null
+ }
+ }
+
+ private fun parsePageResponse(json: String, expectedPage: Int): Triple {
+ return try {
+ val obj = JSONObject(json)
+ val cards = obj.getJSONArray("cards").toString()
+ val totalPages = obj.optInt("totalPages", 1)
+ Triple(cards, SyncStatus.OK, totalPages)
+ } catch (_: Exception) {
+ Log.w(TAG, "Unparseable response from phone for page $expectedPage")
+ Triple(null, SyncStatus.PHONE_NOT_REACHABLE, 1)
+ }
+ }
+}
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 000000000..1a4abcc21
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt
@@ -0,0 +1,111 @@
+package me.hackerchick.catima.wear
+
+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.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
+import protect.card_locker.shared.BluetoothPermissionHelper
+
+class MainActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "CatimaWear"
+ }
+
+ @Volatile private var fetchInFlight = false
+ private var protocolIncompatible = 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 syncStatus by WearCardRepository.syncStatus.collectAsState()
+
+ SwipeDismissableNavHost(
+ navController = navController,
+ startDestination = "card_list"
+ ) {
+ composable("card_list") {
+ CardListScreen(
+ cards = cards,
+ syncStatus = syncStatus,
+ 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 || protocolIncompatible) return
+ BluetoothPermissionHelper.requestBluetoothConnectIfNeeded(
+ this,
+ btPermissionLauncher
+ ) {
+ requestCardsFromPhone()
+ }
+ }
+
+ private fun requestCardsFromPhone() {
+ fetchInFlight = true
+ WearCardRepository.setSyncStatus(SyncStatus.SYNCING)
+ BluetoothCardClient.fetchCards(this) { json, status ->
+ fetchInFlight = false
+ when (status) {
+ SyncStatus.OK -> {
+ if (json != null) {
+ Log.d(TAG, "Got cards via Bluetooth")
+ WearCardRepository.updateCards(this, json)
+ } else {
+ WearCardRepository.setSyncStatus(SyncStatus.PHONE_NOT_REACHABLE)
+ }
+ }
+ SyncStatus.WATCH_OUTDATED -> {
+ Log.w(TAG, "Wear app is outdated")
+ protocolIncompatible = true
+ WearCardRepository.setSyncStatus(status)
+ }
+ else -> WearCardRepository.setSyncStatus(status)
+ }
+ }
+ }
+
+}
diff --git a/wear/src/main/java/me/hackerchick/catima/wear/SyncStatus.kt b/wear/src/main/java/me/hackerchick/catima/wear/SyncStatus.kt
new file mode 100644
index 000000000..d3a3151d0
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/SyncStatus.kt
@@ -0,0 +1,15 @@
+package me.hackerchick.catima.wear
+
+import androidx.annotation.StringRes
+
+enum class SyncStatus(@StringRes val labelRes: Int?) {
+ LOADING(null),
+ SYNCING(R.string.syncing),
+ PHONE_NOT_REACHABLE(R.string.sync_failed),
+ PHONE_OUTDATED(R.string.phone_outdated),
+ WATCH_OUTDATED(R.string.watch_outdated),
+ PERMISSION_DENIED(R.string.bluetooth_permission_denied),
+ BLUETOOTH_DISABLED(R.string.bluetooth_disabled),
+ SYNC_ERROR(R.string.sync_error),
+ OK(null),
+}
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 000000000..d60de54ce
--- /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 000000000..f058e6d31
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt
@@ -0,0 +1,42 @@
+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 _syncStatus = MutableStateFlow(SyncStatus.LOADING)
+ val syncStatus: StateFlow = _syncStatus.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 setSyncStatus(status: SyncStatus) {
+ _syncStatus.value = status
+ }
+
+ fun updateCards(context: Context, json: String) {
+ try {
+ val incoming = WearCard.listFromJson(json)
+ _cards.value = incoming
+ _syncStatus.value = SyncStatus.OK
+ WearCardStore.save(context, incoming)
+ Log.d(TAG, "Cards updated: ${incoming.size} cards")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse cards JSON", e)
+ _syncStatus.value = SyncStatus.SYNC_ERROR
+ }
+ }
+}
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 000000000..1be564a45
--- /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/ui/CardListScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt
new file mode 100644
index 000000000..42d2dfaeb
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt
@@ -0,0 +1,104 @@
+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.SyncStatus
+import me.hackerchick.catima.wear.WearCard
+
+@Composable
+fun CardListScreen(
+ cards: List?,
+ syncStatus: SyncStatus,
+ onCardClick: (WearCard) -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ contentAlignment = Alignment.Center,
+ ) {
+ when {
+ cards == null && syncStatus == SyncStatus.PHONE_NOT_REACHABLE -> {
+ Text(
+ text = stringResource(R.string.phone_not_connected),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null && syncStatus.labelRes != null -> {
+ Text(
+ text = stringResource(syncStatus.labelRes),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ cards == null -> {
+ CircularProgressIndicator()
+ }
+ cards.isEmpty() && syncStatus != SyncStatus.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, key = { it.id }) { 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()
+ },
+ )
+ }
+ val footerLabel = syncStatus.labelRes
+ if (footerLabel != null) {
+ item {
+ Text(
+ text = stringResource(footerLabel),
+ 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 000000000..e066f3ce4
--- /dev/null
+++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt
@@ -0,0 +1,174 @@
+package me.hackerchick.catima.wear.ui
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.view.WindowManager
+import androidx.activity.compose.LocalActivity
+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.Color as ComposeColor
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.CircularProgressIndicator
+import androidx.wear.compose.material.MaterialTheme
+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(MaterialTheme.colors.background),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (card == null) {
+ 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 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 = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+ 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(ComposeColor.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 = ComposeColor.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 = ComposeColor.Black,
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.no_barcode),
+ textAlign = TextAlign.Center,
+ color = ComposeColor.Black,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = barcodeValue,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ color = ComposeColor.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 000000000..f22e99abd
--- /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 000000000..1123a86f4
--- /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 000000000..f4b23782b
--- /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 000000000..d378acd7a
--- /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 000000000..757ce3877
--- /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, your watch is connected and WearOS is enabled in Catima settings.
+ 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.
+