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. +