From bf658e442c8bc36c7c25761a49dec816474f1f4d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 02:27:23 +0200 Subject: [PATCH 01/24] feat: add WearOS app with GMS-based Wearable Data Layer transport --- .gitignore | 1 + app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 11 ++ .../protect/card_locker/WearDataService.kt | 77 ++++++++++ build.gradle.kts | 2 + gradle/libs.versions.toml | 10 ++ settings.gradle.kts | 1 + wear/build.gradle.kts | 57 ++++++++ wear/proguard-rules.pro | 1 + wear/src/main/AndroidManifest.xml | 42 ++++++ .../hackerchick/catima/wear/MainActivity.kt | 87 ++++++++++++ .../me/hackerchick/catima/wear/WearCard.kt | 44 ++++++ .../catima/wear/WearCardRepository.kt | 33 +++++ .../catima/wear/WearDataListenerService.kt | 20 +++ .../hackerchick/catima/wear/WearProtocol.kt | 16 +++ .../catima/wear/ui/CardListScreen.kt | 83 +++++++++++ .../catima/wear/ui/CardViewScreen.kt | 134 ++++++++++++++++++ .../catima/wear/ui/theme/CatimaWearTheme.kt | 9 ++ .../res/drawable/ic_launcher_background.xml | 9 ++ .../res/drawable/ic_launcher_foreground.xml | 59 ++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + wear/src/main/res/values/strings.xml | 9 ++ 22 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/protect/card_locker/WearDataService.kt create mode 100644 wear/build.gradle.kts create mode 100644 wear/proguard-rules.pro create mode 100644 wear/src/main/AndroidManifest.xml create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt create mode 100644 wear/src/main/res/drawable/ic_launcher_background.xml create mode 100644 wear/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 wear/src/main/res/values/strings.xml diff --git a/.gitignore b/.gitignore index 538763b64b..39f4b560ee 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ /app/*.log /app/build /app/release +/wear/build /.idea/* !/.idea/icon.svg # Bundle diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c010928beb..a7db28b728 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,7 +43,6 @@ android { ) } debug { - applicationIdSuffix = ".debug" } } @@ -146,6 +145,9 @@ dependencies { implementation(libs.com.jaredrummler.colorpicker) implementation(libs.net.lingala.zip4j.zip4j) + // Wearable Data Layer + implementation(libs.com.google.android.gms.play.services.wearable) + // Crash reporting implementation(libs.bundles.acra) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed2c6f41ff..f92cc290cd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -223,5 +223,16 @@ + + + + + + + + diff --git a/app/src/main/java/protect/card_locker/WearDataService.kt b/app/src/main/java/protect/card_locker/WearDataService.kt new file mode 100644 index 0000000000..63f4ab9394 --- /dev/null +++ b/app/src/main/java/protect/card_locker/WearDataService.kt @@ -0,0 +1,77 @@ +package protect.card_locker + +import android.util.Log +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import com.google.android.gms.wearable.WearableListenerService +import org.json.JSONArray +import org.json.JSONObject + +class WearDataService : WearableListenerService() { + + companion object { + private const val TAG = "CatimaWearDataService" + private const val PATH_CARDS_REQUEST = "/catima/cards/request" + private const val PATH_CARDS_RESPONSE = "/catima/cards/response" + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + Log.d(TAG, "Received message: ${messageEvent.path} from ${messageEvent.sourceNodeId}") + when (messageEvent.path) { + PATH_CARDS_REQUEST -> sendCardsToNode(messageEvent.sourceNodeId) + } + } + + private fun sendCardsToNode(nodeId: String) { + try { + val dbHelper = DBHelper(this) + val db = dbHelper.readableDatabase + val cursor = db.query( + DBHelper.LoyaltyCardDbIds.TABLE, + arrayOf( + DBHelper.LoyaltyCardDbIds.ID, + DBHelper.LoyaltyCardDbIds.STORE, + DBHelper.LoyaltyCardDbIds.CARD_ID, + DBHelper.LoyaltyCardDbIds.BARCODE_ID, + DBHelper.LoyaltyCardDbIds.BARCODE_TYPE, + DBHelper.LoyaltyCardDbIds.HEADER_COLOR, + DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS, + ), + "${DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS} = 0", + null, + null, + null, + "${DBHelper.LoyaltyCardDbIds.STORE} COLLATE NOCASE ASC" + ) + + val array = JSONArray() + cursor.use { + val idIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID) + val storeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE) + val cardIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.CARD_ID) + val barcodeIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_ID) + val barcodeTypeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE) + val headerColorIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.HEADER_COLOR) + + while (it.moveToNext()) { + val obj = JSONObject() + obj.put("id", it.getInt(idIdx)) + obj.put("store", it.getString(storeIdx) ?: "") + obj.put("cardId", it.getString(cardIdIdx) ?: "") + obj.put("barcodeId", if (it.isNull(barcodeIdIdx)) JSONObject.NULL else it.getString(barcodeIdIdx)) + obj.put("barcodeType", if (it.isNull(barcodeTypeIdx)) JSONObject.NULL else it.getString(barcodeTypeIdx)) + obj.put("headerColor", if (it.isNull(headerColorIdx)) JSONObject.NULL else it.getInt(headerColorIdx)) + array.put(obj) + } + } + + val json = array.toString() + val messageClient = Wearable.getMessageClient(this) + messageClient.sendMessage(nodeId, PATH_CARDS_RESPONSE, json.toByteArray(Charsets.UTF_8)) + .addOnSuccessListener { Log.d(TAG, "Cards sent to node $nodeId (${array.length()} cards)") } + .addOnFailureListener { e -> Log.e(TAG, "Failed to send cards to node $nodeId", e) } + } catch (e: Exception) { + Log.e(TAG, "Error reading cards from DB", e) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e6d03252e1..929fcc46bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,9 @@ plugins { alias(libs.plugins.com.android.application) apply false + alias(libs.plugins.com.android.library) apply false alias(libs.plugins.org.jetbrains.kotlin.android) apply false + alias(libs.plugins.org.jetbrains.kotlin.plugin.compose) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d1841f2ad..2c6d1a6a04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,9 @@ acra = "5.13.1" # Testing androidXTest = "1.7.0" +# Wear OS +wearCompose = "1.6.2" + [libraries] # AndroidX androidx-appcompat-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" } @@ -42,6 +45,12 @@ net-lingala-zip4j-zip4j = { group = "net.lingala.zip4j", name = "zip4j", version ch-acra-acra-mail = { group = "ch.acra", name = "acra-mail", version.ref = "acra" } ch-acra-acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = "acra" } +# Wear OS +androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" } +androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" } +androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" } +com-google-android-gms-play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version = "18.2.0" } + # Testing androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidXTest" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTest" } @@ -54,6 +63,7 @@ org-robolectric-robolectric = { group = "org.robolectric", name = "robolectric", [plugins] com-android-application = { id = "com.android.application", version = "9.2.1" } +com-android-library = { id = "com.android.library", version = "9.2.1" } org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } org-jetbrains-kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ce82c4000..aba0d3ef15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,4 @@ dependencyResolutionManagement { } rootProject.name = "Catima" include(":app") +include(":wear") diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 0000000000..7fff2e9233 --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.com.android.application) + alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.org.jetbrains.kotlin.plugin.compose) +} + +kotlin { + jvmToolchain(21) +} + +android { + namespace = "me.hackerchick.catima.wear" + compileSdk = 36 + + defaultConfig { + applicationId = "me.hackerchick.catima.wear" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +dependencies { + implementation(libs.androidx.core.core.ktx) + implementation(libs.androidx.activity.activity.compose) + + // Wear OS Compose + implementation(libs.androidx.wear.compose.material) + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.navigation) + + // Wearable Data Layer API + implementation(libs.com.google.android.gms.play.services.wearable) + + // ZXing for barcode rendering + implementation(libs.com.google.zxing.core) +} diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 0000000000..da1f1d46e9 --- /dev/null +++ b/wear/proguard-rules.pro @@ -0,0 +1 @@ +-keep class me.hackerchick.catima.wear.** { *; } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..71ef3e3e89 --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt new file mode 100644 index 0000000000..b262ea4af7 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -0,0 +1,87 @@ +package me.hackerchick.catima.wear + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +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 com.google.android.gms.wearable.Wearable +import me.hackerchick.catima.wear.ui.CardListScreen +import me.hackerchick.catima.wear.ui.CardViewScreen +import me.hackerchick.catima.wear.ui.theme.CatimaWearTheme + +class MainActivity : ComponentActivity() { + + companion object { + private const val TAG = "CatimaWear" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestCardsFromPhone() + + setContent { + CatimaWearTheme { + val navController = rememberSwipeDismissableNavController() + val cards by WearCardRepository.cards.collectAsState() + val phoneNotReachable by WearCardRepository.phoneNotReachable.collectAsState() + + SwipeDismissableNavHost( + navController = navController, + startDestination = "card_list" + ) { + composable("card_list") { + CardListScreen( + cards = cards, + phoneNotReachable = phoneNotReachable, + 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) + } + } + } + } + } + + private fun requestCardsFromPhone() { + val messageClient = Wearable.getMessageClient(this) + Wearable.getNodeClient(this).connectedNodes + .addOnSuccessListener { nodes -> + Log.d(TAG, "Connected nodes: ${nodes.map { it.displayName }}") + if (nodes.isEmpty()) { + Log.w(TAG, "No connected nodes found - phone may not be paired or app not installed") + WearCardRepository.setPhoneNotReachable() + } else { + nodes.forEach { node -> + Log.d(TAG, "Sending card request to ${node.displayName} (${node.id})") + messageClient.sendMessage(node.id, WearProtocol.PATH_CARDS_REQUEST, ByteArray(0)) + .addOnSuccessListener { Log.d(TAG, "Request sent to ${node.displayName}") } + .addOnFailureListener { e -> + Log.e(TAG, "Failed to send request to ${node.displayName}", e) + WearCardRepository.setPhoneNotReachable() + } + } + } + } + .addOnFailureListener { e -> + Log.e(TAG, "Failed to get connected nodes", e) + WearCardRepository.setPhoneNotReachable() + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt new file mode 100644 index 0000000000..f43fe9de89 --- /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 = json.optString("barcodeId").takeIf { it.isNotEmpty() }, + barcodeType = json.optString("barcodeType").takeIf { it.isNotEmpty() }, + headerColor = if (json.isNull("headerColor")) null else json.getInt("headerColor"), + ) + + fun listToJson(cards: List): String { + val array = JSONArray() + cards.forEach { array.put(it.toJson()) } + return array.toString() + } + + fun listFromJson(json: String): List { + val array = JSONArray(json) + return (0 until array.length()).map { fromJson(array.getJSONObject(it)) } + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt new file mode 100644 index 0000000000..527665c2d4 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -0,0 +1,33 @@ +package me.hackerchick.catima.wear + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object WearCardRepository { + private val _cards = MutableStateFlow?>(null) + val cards: StateFlow?> = _cards.asStateFlow() + + private val _cardDetail = MutableStateFlow(null) + val cardDetail: StateFlow = _cardDetail.asStateFlow() + + private val _phoneNotReachable = MutableStateFlow(false) + val phoneNotReachable: StateFlow = _phoneNotReachable.asStateFlow() + + fun setPhoneNotReachable() { + if (_cards.value == null) _phoneNotReachable.value = true + } + + fun updateCards(json: String) { + _phoneNotReachable.value = false + _cards.value = WearCard.listFromJson(json) + } + + fun updateCardDetail(json: String) { + _cardDetail.value = WearCard.fromJson(org.json.JSONObject(json)) + } + + fun clearCardDetail() { + _cardDetail.value = null + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt new file mode 100644 index 0000000000..629982a5f8 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt @@ -0,0 +1,20 @@ +package me.hackerchick.catima.wear + +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.WearableListenerService + +class WearDataListenerService : WearableListenerService() { + + override fun onMessageReceived(messageEvent: MessageEvent) { + when (messageEvent.path) { + WearProtocol.PATH_CARDS_RESPONSE -> { + val json = String(messageEvent.data, Charsets.UTF_8) + WearCardRepository.updateCards(json) + } + WearProtocol.PATH_CARD_DETAIL_RESPONSE -> { + val json = String(messageEvent.data, Charsets.UTF_8) + WearCardRepository.updateCardDetail(json) + } + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt new file mode 100644 index 0000000000..78d9314a44 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt @@ -0,0 +1,16 @@ +package me.hackerchick.catima.wear + +object WearProtocol { + const val PATH_CARDS_REQUEST = "/catima/cards/request" + const val PATH_CARDS_RESPONSE = "/catima/cards/response" + const val PATH_CARD_DETAIL_REQUEST = "/catima/card/detail/request" + const val PATH_CARD_DETAIL_RESPONSE = "/catima/card/detail/response" + + const val KEY_CARDS_JSON = "cards_json" + const val KEY_CARD_ID = "card_id" + const val KEY_CARD_STORE = "card_store" + const val KEY_CARD_CARD_ID = "card_card_id" + const val KEY_CARD_BARCODE_ID = "card_barcode_id" + const val KEY_CARD_BARCODE_TYPE = "card_barcode_type" + const val KEY_CARD_HEADER_COLOR = "card_header_color" +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt new file mode 100644 index 0000000000..be2b20cf04 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt @@ -0,0 +1,83 @@ +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.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Text +import me.hackerchick.catima.wear.R +import me.hackerchick.catima.wear.WearCard + +@Composable +fun CardListScreen( + cards: List?, + phoneNotReachable: Boolean, + onCardClick: (WearCard) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + when { + cards == null && phoneNotReachable -> { + Text( + text = stringResource(R.string.phone_not_connected), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null -> { + CircularProgressIndicator() + } + cards.isEmpty() -> { + Text( + text = stringResource(R.string.no_cards), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + else -> { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(cards) { card -> + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text( + text = card.store, + maxLines = 1, + ) + }, + onClick = { onCardClick(card) }, + colors = if (card.headerColor != null) { + ChipDefaults.chipColors(backgroundColor = Color(card.headerColor)) + } else { + ChipDefaults.primaryChipColors() + }, + ) + } + } + } + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt new file mode 100644 index 0000000000..4b4131e245 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt @@ -0,0 +1,134 @@ +package me.hackerchick.catima.wear.ui + +import android.graphics.Bitmap +import android.graphics.Color +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.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.Text +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import me.hackerchick.catima.wear.R +import me.hackerchick.catima.wear.WearCard + +@Composable +fun CardViewScreen(card: WearCard?) { + Box( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.Black), + contentAlignment = Alignment.Center, + ) { + if (card == null) { + androidx.wear.compose.material.CircularProgressIndicator() + } else { + CardDetail(card = card) + } + } +} + +@Composable +private fun CardDetail(card: WearCard) { + val barcodeValue = card.barcodeId ?: card.cardId + val barcodeType = card.barcodeType + + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = card.store, + textAlign = TextAlign.Center, + maxLines = 2, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (barcodeType != null) { + val barcodeBitmap = remember(barcodeValue, barcodeType) { + generateBarcode(barcodeValue, barcodeType) + } + + if (barcodeBitmap != null) { + val format = runCatching { BarcodeFormat.valueOf(barcodeType) }.getOrNull() + val isSquare = format == BarcodeFormat.QR_CODE + || format == BarcodeFormat.AZTEC + || format == BarcodeFormat.DATA_MATRIX + + Image( + bitmap = barcodeBitmap.asImageBitmap(), + contentDescription = card.cardId, + modifier = Modifier + .fillMaxWidth() + .then( + if (isSquare) Modifier.aspectRatio(1f) + else Modifier.height(60.dp) + ), + ) + } else { + Text( + text = stringResource(R.string.no_barcode), + textAlign = TextAlign.Center, + ) + } + } else { + Text( + text = stringResource(R.string.no_barcode), + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = barcodeValue, + textAlign = TextAlign.Center, + maxLines = 2, + ) + } +} + +private fun generateBarcode(value: String, formatName: String): Bitmap? { + return try { + val format = BarcodeFormat.valueOf(formatName) + val isSquare = format == BarcodeFormat.QR_CODE + || format == BarcodeFormat.AZTEC + || format == BarcodeFormat.DATA_MATRIX + + 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 + } + + Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } catch (e: Exception) { + null + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt new file mode 100644 index 0000000000..f22e99abd8 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt @@ -0,0 +1,9 @@ +package me.hackerchick.catima.wear.ui.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +@Composable +fun CatimaWearTheme(content: @Composable () -> Unit) { + MaterialTheme(content = content) +} diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..1123a86f4f --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..f4b23782b3 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..d378acd7ac --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000000..02d68e7797 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Catima + No cards found.\nOpen Catima on your phone to add cards. + Could not reach Catima on your phone.\nMake sure the app is installed and your watch is connected. + Loading cards… + No barcode available for this card. + Card ID + From 5a6aca2464b6ab59ac34b459a2ece2763b8f8977 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 02:34:00 +0200 Subject: [PATCH 02/24] feat: add GMS-free Bluetooth RFCOMM transport for Wear OS - BluetoothServerService: RFCOMM server on phone, listens for CARDS_REQUEST and responds with card list JSON over a newline-delimited protocol - BluetoothCardClient: watch-side client, iterates bonded devices trying the Catima UUID, falls back to GMS Wearable Data Layer if BT fails - Runtime BLUETOOTH_CONNECT permission requests on both phone and watch - standalone=true + wearable lib optional on watch (no GMS dependency) - Bluetooth-first with automatic GMS fallback ensures compatibility with both GrapheneOS/FOSS and standard Android+GMS setups --- app/src/main/AndroidManifest.xml | 7 + .../card_locker/BluetoothServerService.kt | 154 ++++++++++++++++++ .../LoyaltyCardLockerApplication.java | 4 + .../java/protect/card_locker/MainActivity.kt | 19 +++ wear/src/main/AndroidManifest.xml | 8 +- .../catima/wear/BluetoothCardClient.kt | 79 +++++++++ .../hackerchick/catima/wear/MainActivity.kt | 43 ++++- .../hackerchick/catima/wear/WearProtocol.kt | 5 + 8 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/protect/card_locker/BluetoothServerService.kt create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f92cc290cd..30bc7bc69b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,9 @@ + + + + + { + val json = buildCardsJson() + writer.println(json) + writer.flush() + Log.d(TAG, "Sent ${json.length} bytes to $deviceName") + } + else -> Log.w(TAG, "Unknown command: $command") + } + } catch (e: Exception) { + Log.e(TAG, "Error handling connection from $deviceName", e) + } finally { + try { socket.close() } catch (e: Exception) { } + } + } + + private fun buildCardsJson(): String { + val dbHelper = DBHelper(this@BluetoothServerService) + val db = dbHelper.readableDatabase + val cursor = db.query( + DBHelper.LoyaltyCardDbIds.TABLE, + arrayOf( + DBHelper.LoyaltyCardDbIds.ID, + DBHelper.LoyaltyCardDbIds.STORE, + DBHelper.LoyaltyCardDbIds.CARD_ID, + DBHelper.LoyaltyCardDbIds.BARCODE_ID, + DBHelper.LoyaltyCardDbIds.BARCODE_TYPE, + DBHelper.LoyaltyCardDbIds.HEADER_COLOR, + DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS, + ), + "${DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS} = 0", + null, null, null, + "${DBHelper.LoyaltyCardDbIds.STORE} COLLATE NOCASE ASC" + ) + val array = JSONArray() + cursor.use { + val idIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID) + val storeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE) + val cardIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.CARD_ID) + val barcodeIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_ID) + val barcodeTypeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE) + val headerColorIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.HEADER_COLOR) + while (it.moveToNext()) { + array.put(JSONObject().apply { + put("id", it.getInt(idIdx)) + put("store", it.getString(storeIdx) ?: "") + put("cardId", it.getString(cardIdIdx) ?: "") + put("barcodeId", if (it.isNull(barcodeIdIdx)) JSONObject.NULL else it.getString(barcodeIdIdx)) + put("barcodeType", if (it.isNull(barcodeTypeIdx)) JSONObject.NULL else it.getString(barcodeTypeIdx)) + put("headerColor", if (it.isNull(headerColorIdx)) JSONObject.NULL else it.getInt(headerColorIdx)) + }) + } + } + return array.toString() + } + + fun cancel() { + running = false + try { serverSocket?.close() } catch (e: Exception) { } + } + } +} diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java index 8865ae2a12..8a2c7f39ba 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java @@ -1,6 +1,7 @@ package protect.card_locker; import android.app.Application; +import android.content.Intent; import androidx.appcompat.app.AppCompatDelegate; @@ -41,5 +42,8 @@ public void onCreate() { // Set theme Settings settings = new Settings(this); AppCompatDelegate.setDefaultNightMode(settings.getTheme()); + + // Start Bluetooth server for Wear OS companion + startService(new Intent(this, BluetoothServerService.class)); } } diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index d153823f59..66acc53c4b 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -20,7 +20,9 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.core.content.ContextCompat import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -62,6 +64,12 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { private lateinit var mBarcodeScannerLauncher: ActivityResultLauncher private lateinit var mSettingsLauncher: ActivityResultLauncher + private val mBtPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) startService(Intent(this, BluetoothServerService::class.java)) + } + private val mCurrentActionModeCallback: ActionMode.Callback = object : ActionMode.Callback { override fun onCreateActionMode(inputMode: ActionMode, inputMenu: Menu?): Boolean { inputMode.menuInflater.inflate(R.menu.card_longclick_menu, inputMenu) @@ -247,6 +255,17 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { } }.start() + // Request Bluetooth permission for Wear OS companion (Android 12+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) + != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) + } else { + startService(Intent(this, BluetoothServerService::class.java)) + } + } + // We should extract the share intent after we called the super.onCreate as it may need to spawn a dialog window and the app needs to be initialized to not crash extractIntentFields(intent) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 71ef3e3e89..eebe5e4189 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + + android:required="false" /> + android:value="true" /> Unit) { + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + Log.w(TAG, "BLUETOOTH_CONNECT permission not granted") + onResult(null) + 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) + return + } + + val bondedDevices = adapter.bondedDevices + if (bondedDevices.isEmpty()) { + Log.w(TAG, "No bonded devices found") + onResult(null) + return + } + + Log.d(TAG, "Trying ${bondedDevices.size} bonded device(s)") + + Thread { + var result: String? = null + for (device in bondedDevices) { + val deviceName = try { device.name } catch (e: Exception) { "unknown" } + Log.d(TAG, "Trying $deviceName") + var socket: BluetoothSocket? = null + try { + socket = device.createRfcommSocketToServiceRecord(WearProtocol.BT_SERVICE_UUID) + socket.connect() + Log.d(TAG, "Connected to $deviceName") + + val writer = PrintWriter(socket.outputStream, false, Charsets.UTF_8) + val reader = BufferedReader(InputStreamReader(socket.inputStream, Charsets.UTF_8)) + + writer.print(WearProtocol.BT_CMD_CARDS_REQUEST) + writer.flush() + + val json = reader.readLine()?.trim() ?: "" + if (json.isNotEmpty()) { + Log.d(TAG, "Received ${json.length} bytes from $deviceName") + result = json + break + } + } catch (e: Exception) { + Log.d(TAG, "Failed to connect to $deviceName: ${e.message}") + } finally { + try { socket?.close() } catch (e: Exception) { } + } + } + + if (result == null) { + Log.w(TAG, "No Catima phone found among bonded devices") + } + onResult(result) + }.start() + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index b262ea4af7..31360aa70d 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -1,9 +1,14 @@ package me.hackerchick.catima.wear +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavType @@ -22,10 +27,23 @@ class MainActivity : ComponentActivity() { private const val TAG = "CatimaWear" } + private val btPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + requestCardsFromPhone() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - requestCardsFromPhone() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) + } else { + requestCardsFromPhone() + } setContent { CatimaWearTheme { @@ -60,27 +78,38 @@ class MainActivity : ComponentActivity() { } private fun requestCardsFromPhone() { + BluetoothCardClient.fetchCards(this) { json -> + if (json != null) { + Log.d(TAG, "Got cards via Bluetooth") + WearCardRepository.updateCards(json) + } else { + Log.d(TAG, "Bluetooth failed, trying GMS Wearable") + requestCardsViaGms() + } + } + } + + private fun requestCardsViaGms() { val messageClient = Wearable.getMessageClient(this) Wearable.getNodeClient(this).connectedNodes .addOnSuccessListener { nodes -> - Log.d(TAG, "Connected nodes: ${nodes.map { it.displayName }}") + Log.d(TAG, "GMS connected nodes: ${nodes.map { it.displayName }}") if (nodes.isEmpty()) { - Log.w(TAG, "No connected nodes found - phone may not be paired or app not installed") + Log.w(TAG, "No GMS nodes found") WearCardRepository.setPhoneNotReachable() } else { nodes.forEach { node -> - Log.d(TAG, "Sending card request to ${node.displayName} (${node.id})") messageClient.sendMessage(node.id, WearProtocol.PATH_CARDS_REQUEST, ByteArray(0)) - .addOnSuccessListener { Log.d(TAG, "Request sent to ${node.displayName}") } + .addOnSuccessListener { Log.d(TAG, "GMS request sent to ${node.displayName}") } .addOnFailureListener { e -> - Log.e(TAG, "Failed to send request to ${node.displayName}", e) + Log.e(TAG, "GMS send failed to ${node.displayName}", e) WearCardRepository.setPhoneNotReachable() } } } } .addOnFailureListener { e -> - Log.e(TAG, "Failed to get connected nodes", e) + Log.e(TAG, "Failed to get GMS nodes", e) WearCardRepository.setPhoneNotReachable() } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt index 78d9314a44..ac4da48f12 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt @@ -1,5 +1,7 @@ package me.hackerchick.catima.wear +import java.util.UUID + object WearProtocol { const val PATH_CARDS_REQUEST = "/catima/cards/request" const val PATH_CARDS_RESPONSE = "/catima/cards/response" @@ -13,4 +15,7 @@ object WearProtocol { const val KEY_CARD_BARCODE_ID = "card_barcode_id" const val KEY_CARD_BARCODE_TYPE = "card_barcode_type" const val KEY_CARD_HEADER_COLOR = "card_header_color" + + val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") + const val BT_CMD_CARDS_REQUEST = "CARDS_REQUEST\n" } From 9f6d0d4ef14a4af612b28c09906fb75e9d113f55 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 03:09:34 +0200 Subject: [PATCH 03/24] fix: resolve two wear card view bugs - Fix barcode not shown for cards with null barcodeId: org.json.optString() returns literal string 'null' for JSON null values; use isNull() check first - Fix QR/Aztec/DataMatrix overflow on round watch: use BoxWithConstraints to measure screen size and apply a 15% round-screen inset to constrain the square barcode to stay within the circular display boundary --- .../me/hackerchick/catima/wear/WearCard.kt | 4 +- .../catima/wear/WearCardRepository.kt | 7 +- .../catima/wear/ui/CardViewScreen.kt | 89 ++++++++++--------- 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt index f43fe9de89..d60de54ce0 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt @@ -25,8 +25,8 @@ data class WearCard( id = json.getInt("id"), store = json.getString("store"), cardId = json.getString("cardId"), - barcodeId = json.optString("barcodeId").takeIf { it.isNotEmpty() }, - barcodeType = json.optString("barcodeType").takeIf { it.isNotEmpty() }, + 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"), ) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt index 527665c2d4..785c75c95d 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -20,7 +20,12 @@ object WearCardRepository { fun updateCards(json: String) { _phoneNotReachable.value = false - _cards.value = WearCard.listFromJson(json) + try { + _cards.value = WearCard.listFromJson(json) + android.util.Log.d("WearCardRepository", "Cards updated: ${_cards.value?.size} cards") + } catch (e: Exception) { + android.util.Log.e("WearCardRepository", "Failed to parse cards JSON", e) + } } fun updateCardDetail(json: String) { 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 index 4b4131e245..61b9cf28c0 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt @@ -6,13 +6,14 @@ 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.aspectRatio 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.remember import androidx.compose.ui.Alignment @@ -49,62 +50,70 @@ private fun CardDetail(card: WearCard) { val barcodeValue = card.barcodeId ?: card.cardId val barcodeType = card.barcodeType - Column( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + contentAlignment = Alignment.Center, ) { - Text( - text = card.store, - textAlign = TextAlign.Center, - maxLines = 2, - ) + val screenSize = minOf(maxWidth, maxHeight) + val roundInset = screenSize * 0.15f + val barcodeMaxSize = screenSize - roundInset * 2 - Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = card.store, + textAlign = TextAlign.Center, + maxLines = 2, + ) - if (barcodeType != null) { - val barcodeBitmap = remember(barcodeValue, barcodeType) { - generateBarcode(barcodeValue, barcodeType) - } + Spacer(modifier = Modifier.height(8.dp)) - if (barcodeBitmap != null) { - val format = runCatching { BarcodeFormat.valueOf(barcodeType) }.getOrNull() - val isSquare = format == BarcodeFormat.QR_CODE - || format == BarcodeFormat.AZTEC - || format == BarcodeFormat.DATA_MATRIX - - Image( - bitmap = barcodeBitmap.asImageBitmap(), - contentDescription = card.cardId, - modifier = Modifier - .fillMaxWidth() - .then( - if (isSquare) Modifier.aspectRatio(1f) - else Modifier.height(60.dp) - ), - ) + if (barcodeType != null) { + val barcodeBitmap = remember(barcodeValue, barcodeType) { + generateBarcode(barcodeValue, barcodeType) + } + + if (barcodeBitmap != null) { + val format = runCatching { BarcodeFormat.valueOf(barcodeType) }.getOrNull() + val isSquare = format == BarcodeFormat.QR_CODE + || format == BarcodeFormat.AZTEC + || format == BarcodeFormat.DATA_MATRIX + + Image( + bitmap = barcodeBitmap.asImageBitmap(), + contentDescription = card.cardId, + modifier = if (isSquare) { + Modifier.size(barcodeMaxSize) + } else { + Modifier.fillMaxWidth(0.85f).height(60.dp) + }, + ) + } else { + Text( + text = stringResource(R.string.no_barcode), + textAlign = TextAlign.Center, + ) + } } else { Text( text = stringResource(R.string.no_barcode), textAlign = TextAlign.Center, ) } - } else { + + Spacer(modifier = Modifier.height(4.dp)) + Text( - text = stringResource(R.string.no_barcode), + text = barcodeValue, textAlign = TextAlign.Center, + maxLines = 2, ) } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = barcodeValue, - textAlign = TextAlign.Center, - maxLines = 2, - ) } } From 302af3d3cc30d9225a6073fc5634bd40fb15fb7f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 10:01:28 +0200 Subject: [PATCH 04/24] fix: re-fetch cards on onResume to recover from post-reboot spinner After a reboot the BT fetch completes while the watch screen is still off/settling. The StateFlow update is lost because Compose hasn't composed yet in that window. Fix by: - Moving card fetch to onResume so it runs every time the user wakes the screen, not just once in onCreate - Adding fetchInFlight guard to prevent concurrent duplicate requests - Calling WearCardRepository.reset() at the start of each fetch to clear stale state and re-show the spinner during refresh --- .../hackerchick/catima/wear/MainActivity.kt | 31 +++++++++++++------ .../catima/wear/WearCardRepository.kt | 5 +++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index 31360aa70d..2bdd64c09d 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -27,6 +27,8 @@ class MainActivity : ComponentActivity() { private const val TAG = "CatimaWear" } + @Volatile private var fetchInFlight = false + private val btPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { @@ -36,15 +38,6 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) - != PackageManager.PERMISSION_GRANTED - ) { - btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) - } else { - requestCardsFromPhone() - } - setContent { CatimaWearTheme { val navController = rememberSwipeDismissableNavController() @@ -77,8 +70,28 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + maybeRequestCards() + } + + private fun maybeRequestCards() { + if (fetchInFlight) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) + } else { + requestCardsFromPhone() + } + } + private fun requestCardsFromPhone() { + fetchInFlight = true + WearCardRepository.reset() BluetoothCardClient.fetchCards(this) { json -> + fetchInFlight = false if (json != null) { Log.d(TAG, "Got cards via Bluetooth") WearCardRepository.updateCards(json) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt index 785c75c95d..1fe3e88720 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -14,6 +14,11 @@ object WearCardRepository { private val _phoneNotReachable = MutableStateFlow(false) val phoneNotReachable: StateFlow = _phoneNotReachable.asStateFlow() + fun reset() { + _cards.value = null + _phoneNotReachable.value = false + } + fun setPhoneNotReachable() { if (_cards.value == null) _phoneNotReachable.value = true } From 60df23c8878c5a283807c08e28ea71adced38d5e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 10:07:46 +0200 Subject: [PATCH 05/24] fix: prevent BluetoothServerService from being killed by app idle Android's app standby mechanism stops background services when the app is idle (~5 min). Promote BluetoothServerService to a foreground service with a silent low-priority notification so the OS cannot kill it. - Add FOREGROUND_SERVICE + FOREGROUND_SERVICE_CONNECTED_DEVICE permissions - Add foregroundServiceType="connectedDevice" to manifest declaration - Call startForeground() on service start with a silent IMPORTANCE_MIN notification (uses FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE on API 34+) - Restart the service via stopSelf() if the accept loop exits unexpectedly, relying on START_STICKY to have the OS bring it back up --- app/src/main/AndroidManifest.xml | 5 ++- .../card_locker/BluetoothServerService.kt | 34 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 30bc7bc69b..1301804543 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,8 @@ + + + android:exported="false" + android:foregroundServiceType="connectedDevice" /> = 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 @@ -80,6 +110,10 @@ class BluetoothServerService : Service() { 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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cbdea0793..1a4d0092ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -351,4 +351,6 @@ NFC is paused Change settings Failed to pause NFC + Wear OS companion + Catima watch sync active From a1d00e9219c16fdc2088e732f8453da9475994b7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 17:41:09 +0200 Subject: [PATCH 06/24] feat: keep screen on at full brightness while barcode is displayed - Add KeepScreenOnAtMaxBrightness() composable using DisposableEffect: sets FLAG_KEEP_SCREEN_ON and BRIGHTNESS_OVERRIDE_FULL on the window when CardDetail is composed, restores original flags/brightness on dispose - Switch CardDetail to white background with black text/barcode so codes are displayed as dark-on-light (required for scanner compatibility and better contrast under store lighting) --- .../catima/wear/ui/CardViewScreen.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 index 61b9cf28c0..68229dab1f 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt @@ -1,7 +1,9 @@ package me.hackerchick.catima.wear.ui +import android.app.Activity import android.graphics.Bitmap import android.graphics.Color +import android.view.WindowManager import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -15,10 +17,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -45,14 +49,36 @@ fun CardViewScreen(card: WearCard?) { } } +@Composable +private fun KeepScreenOnAtMaxBrightness() { + val activity = LocalContext.current as? Activity ?: return + DisposableEffect(Unit) { + val window = activity.window + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val originalBrightness = window.attributes.screenBrightness + val params = window.attributes + params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + window.attributes = params + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val restore = window.attributes + restore.screenBrightness = originalBrightness + window.attributes = restore + } + } +} + @Composable private fun CardDetail(card: WearCard) { val barcodeValue = card.barcodeId ?: card.cardId val barcodeType = card.barcodeType + KeepScreenOnAtMaxBrightness() + BoxWithConstraints( modifier = Modifier .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.White) .padding(8.dp), contentAlignment = Alignment.Center, ) { @@ -69,6 +95,7 @@ private fun CardDetail(card: WearCard) { text = card.store, textAlign = TextAlign.Center, maxLines = 2, + color = androidx.compose.ui.graphics.Color.Black, ) Spacer(modifier = Modifier.height(8.dp)) @@ -97,12 +124,14 @@ private fun CardDetail(card: WearCard) { Text( text = stringResource(R.string.no_barcode), textAlign = TextAlign.Center, + color = androidx.compose.ui.graphics.Color.Black, ) } } else { Text( text = stringResource(R.string.no_barcode), textAlign = TextAlign.Center, + color = androidx.compose.ui.graphics.Color.Black, ) } @@ -112,6 +141,7 @@ private fun CardDetail(card: WearCard) { text = barcodeValue, textAlign = TextAlign.Center, maxLines = 2, + color = androidx.compose.ui.graphics.Color.Black, ) } } From 744a74538a3482716d85063a24265461dbaee25a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 22:17:19 +0200 Subject: [PATCH 07/24] refactor: remove all GMS/play-services-wearable dependencies The Bluetooth RFCOMM transport is now the sole data channel, making GMS a hard dependency with no benefit. Removed entirely: - WearDataListenerService (wear) - GMS WearableListenerService - WearDataService (phone) - GMS WearableListenerService - requestCardsViaGms() in wear MainActivity - GMS path/key constants from WearProtocol (only BT constants remain) - WearDataListenerService + WearDataService from both manifests - com.google.android.wearable uses-library + standalone meta-data from wear manifest - play-services-wearable from wear/build.gradle.kts - play-services-wearable from app/build.gradle.kts - com-google-android-gms-play-services-wearable from libs.versions.toml BT failure now goes directly to setPhoneNotReachable() showing the 'phone not connected' error state. --- app/build.gradle.kts | 3 - app/src/main/AndroidManifest.xml | 11 --- .../protect/card_locker/WearDataService.kt | 77 ------------------- gradle/libs.versions.toml | 2 +- wear/build.gradle.kts | 3 - wear/src/main/AndroidManifest.xml | 19 ----- .../hackerchick/catima/wear/MainActivity.kt | 29 +------ .../catima/wear/WearDataListenerService.kt | 20 ----- .../hackerchick/catima/wear/WearProtocol.kt | 13 ---- 9 files changed, 3 insertions(+), 174 deletions(-) delete mode 100644 app/src/main/java/protect/card_locker/WearDataService.kt delete mode 100644 wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7db28b728..a5c745b723 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -145,9 +145,6 @@ dependencies { implementation(libs.com.jaredrummler.colorpicker) implementation(libs.net.lingala.zip4j.zip4j) - // Wearable Data Layer - implementation(libs.com.google.android.gms.play.services.wearable) - // Crash reporting implementation(libs.bundles.acra) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1301804543..63b5bfa79b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -233,16 +233,5 @@ android:name=".BluetoothServerService" android:exported="false" android:foregroundServiceType="connectedDevice" /> - - - - - - - - diff --git a/app/src/main/java/protect/card_locker/WearDataService.kt b/app/src/main/java/protect/card_locker/WearDataService.kt deleted file mode 100644 index 63f4ab9394..0000000000 --- a/app/src/main/java/protect/card_locker/WearDataService.kt +++ /dev/null @@ -1,77 +0,0 @@ -package protect.card_locker - -import android.util.Log -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.Wearable -import com.google.android.gms.wearable.WearableListenerService -import org.json.JSONArray -import org.json.JSONObject - -class WearDataService : WearableListenerService() { - - companion object { - private const val TAG = "CatimaWearDataService" - private const val PATH_CARDS_REQUEST = "/catima/cards/request" - private const val PATH_CARDS_RESPONSE = "/catima/cards/response" - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - Log.d(TAG, "Received message: ${messageEvent.path} from ${messageEvent.sourceNodeId}") - when (messageEvent.path) { - PATH_CARDS_REQUEST -> sendCardsToNode(messageEvent.sourceNodeId) - } - } - - private fun sendCardsToNode(nodeId: String) { - try { - val dbHelper = DBHelper(this) - val db = dbHelper.readableDatabase - val cursor = db.query( - DBHelper.LoyaltyCardDbIds.TABLE, - arrayOf( - DBHelper.LoyaltyCardDbIds.ID, - DBHelper.LoyaltyCardDbIds.STORE, - DBHelper.LoyaltyCardDbIds.CARD_ID, - DBHelper.LoyaltyCardDbIds.BARCODE_ID, - DBHelper.LoyaltyCardDbIds.BARCODE_TYPE, - DBHelper.LoyaltyCardDbIds.HEADER_COLOR, - DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS, - ), - "${DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS} = 0", - null, - null, - null, - "${DBHelper.LoyaltyCardDbIds.STORE} COLLATE NOCASE ASC" - ) - - val array = JSONArray() - cursor.use { - val idIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID) - val storeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE) - val cardIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.CARD_ID) - val barcodeIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_ID) - val barcodeTypeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE) - val headerColorIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.HEADER_COLOR) - - while (it.moveToNext()) { - val obj = JSONObject() - obj.put("id", it.getInt(idIdx)) - obj.put("store", it.getString(storeIdx) ?: "") - obj.put("cardId", it.getString(cardIdIdx) ?: "") - obj.put("barcodeId", if (it.isNull(barcodeIdIdx)) JSONObject.NULL else it.getString(barcodeIdIdx)) - obj.put("barcodeType", if (it.isNull(barcodeTypeIdx)) JSONObject.NULL else it.getString(barcodeTypeIdx)) - obj.put("headerColor", if (it.isNull(headerColorIdx)) JSONObject.NULL else it.getInt(headerColorIdx)) - array.put(obj) - } - } - - val json = array.toString() - val messageClient = Wearable.getMessageClient(this) - messageClient.sendMessage(nodeId, PATH_CARDS_RESPONSE, json.toByteArray(Charsets.UTF_8)) - .addOnSuccessListener { Log.d(TAG, "Cards sent to node $nodeId (${array.length()} cards)") } - .addOnFailureListener { e -> Log.e(TAG, "Failed to send cards to node $nodeId", e) } - } catch (e: Exception) { - Log.e(TAG, "Error reading cards from DB", e) - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c6d1a6a04..d746279437 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,7 +49,7 @@ ch-acra-acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = " 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" } -com-google-android-gms-play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version = "18.2.0" } + # Testing androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidXTest" } diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 7fff2e9233..e99c606fda 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -49,9 +49,6 @@ dependencies { implementation(libs.androidx.wear.compose.foundation) implementation(libs.androidx.wear.compose.navigation) - // Wearable Data Layer API - implementation(libs.com.google.android.gms.play.services.wearable) - // ZXing for barcode rendering implementation(libs.com.google.zxing.core) } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index eebe5e4189..5e10a1782f 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -14,14 +14,6 @@ android:supportsRtl="true" android:theme="@android:style/Theme.DeviceDefault"> - - - - - - - - - - - - diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index 2bdd64c09d..7c1d83970f 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -16,7 +16,6 @@ import androidx.navigation.navArgument import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavController -import com.google.android.gms.wearable.Wearable import me.hackerchick.catima.wear.ui.CardListScreen import me.hackerchick.catima.wear.ui.CardViewScreen import me.hackerchick.catima.wear.ui.theme.CatimaWearTheme @@ -96,34 +95,10 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "Got cards via Bluetooth") WearCardRepository.updateCards(json) } else { - Log.d(TAG, "Bluetooth failed, trying GMS Wearable") - requestCardsViaGms() + Log.w(TAG, "Bluetooth failed, phone not reachable") + WearCardRepository.setPhoneNotReachable() } } } - private fun requestCardsViaGms() { - val messageClient = Wearable.getMessageClient(this) - Wearable.getNodeClient(this).connectedNodes - .addOnSuccessListener { nodes -> - Log.d(TAG, "GMS connected nodes: ${nodes.map { it.displayName }}") - if (nodes.isEmpty()) { - Log.w(TAG, "No GMS nodes found") - WearCardRepository.setPhoneNotReachable() - } else { - nodes.forEach { node -> - messageClient.sendMessage(node.id, WearProtocol.PATH_CARDS_REQUEST, ByteArray(0)) - .addOnSuccessListener { Log.d(TAG, "GMS request sent to ${node.displayName}") } - .addOnFailureListener { e -> - Log.e(TAG, "GMS send failed to ${node.displayName}", e) - WearCardRepository.setPhoneNotReachable() - } - } - } - } - .addOnFailureListener { e -> - Log.e(TAG, "Failed to get GMS nodes", e) - WearCardRepository.setPhoneNotReachable() - } - } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt deleted file mode 100644 index 629982a5f8..0000000000 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearDataListenerService.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.hackerchick.catima.wear - -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.WearableListenerService - -class WearDataListenerService : WearableListenerService() { - - override fun onMessageReceived(messageEvent: MessageEvent) { - when (messageEvent.path) { - WearProtocol.PATH_CARDS_RESPONSE -> { - val json = String(messageEvent.data, Charsets.UTF_8) - WearCardRepository.updateCards(json) - } - WearProtocol.PATH_CARD_DETAIL_RESPONSE -> { - val json = String(messageEvent.data, Charsets.UTF_8) - WearCardRepository.updateCardDetail(json) - } - } - } -} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt index ac4da48f12..dd67d7ab28 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt @@ -3,19 +3,6 @@ package me.hackerchick.catima.wear import java.util.UUID object WearProtocol { - const val PATH_CARDS_REQUEST = "/catima/cards/request" - const val PATH_CARDS_RESPONSE = "/catima/cards/response" - const val PATH_CARD_DETAIL_REQUEST = "/catima/card/detail/request" - const val PATH_CARD_DETAIL_RESPONSE = "/catima/card/detail/response" - - const val KEY_CARDS_JSON = "cards_json" - const val KEY_CARD_ID = "card_id" - const val KEY_CARD_STORE = "card_store" - const val KEY_CARD_CARD_ID = "card_card_id" - const val KEY_CARD_BARCODE_ID = "card_barcode_id" - const val KEY_CARD_BARCODE_TYPE = "card_barcode_type" - const val KEY_CARD_HEADER_COLOR = "card_header_color" - val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") const val BT_CMD_CARDS_REQUEST = "CARDS_REQUEST\n" } From aa72cc19b89643629ed162eadeec1e7703b9f296 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 22:27:24 +0200 Subject: [PATCH 08/24] feat: persist cards locally on watch for offline access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards are now stored in SharedPreferences after each successful BT sync. On launch, cached cards are shown immediately before the BT request completes, so the UI is never blank if the phone isn't available. - WearCardStore: new singleton that reads/writes card list JSON to SharedPreferences (catima_wear_cards / cards_json) - WearCardRepository: loadCache() pre-populates _cards from store on app start; updateCards() now persists to store after each sync; new syncing StateFlow; setPhoneNotReachable() always sets the flag so the UI can indicate offline state even when cards are shown - MainActivity: calls loadCache() before setContent so cached cards are available for the first composition; setSyncing(true) replaces reset() so existing cards remain visible during refresh; passes Context to updateCards() - CardListScreen: new syncing param; shows 'Syncing with phone…' footer while BT request is in flight; shows 'Offline – showing cached cards' footer when phone was unreachable but cards exist; full 'phone not connected' screen only shown when cards=null+offline --- .../hackerchick/catima/wear/MainActivity.kt | 8 +++- .../catima/wear/WearCardRepository.kt | 44 +++++++++++-------- .../hackerchick/catima/wear/WearCardStore.kt | 22 ++++++++++ .../catima/wear/ui/CardListScreen.kt | 25 ++++++++++- wear/src/main/res/values/strings.xml | 2 + 5 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index 7c1d83970f..6e2422242b 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -37,10 +37,13 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WearCardRepository.loadCache(this) + setContent { CatimaWearTheme { val navController = rememberSwipeDismissableNavController() val cards by WearCardRepository.cards.collectAsState() + val syncing by WearCardRepository.syncing.collectAsState() val phoneNotReachable by WearCardRepository.phoneNotReachable.collectAsState() SwipeDismissableNavHost( @@ -50,6 +53,7 @@ class MainActivity : ComponentActivity() { composable("card_list") { CardListScreen( cards = cards, + syncing = syncing, phoneNotReachable = phoneNotReachable, onCardClick = { card -> navController.navigate("card_view/${card.id}") @@ -88,12 +92,12 @@ class MainActivity : ComponentActivity() { private fun requestCardsFromPhone() { fetchInFlight = true - WearCardRepository.reset() + WearCardRepository.setSyncing(true) BluetoothCardClient.fetchCards(this) { json -> fetchInFlight = false if (json != null) { Log.d(TAG, "Got cards via Bluetooth") - WearCardRepository.updateCards(json) + WearCardRepository.updateCards(this, json) } else { Log.w(TAG, "Bluetooth failed, phone not reachable") WearCardRepository.setPhoneNotReachable() diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt index 1fe3e88720..8fbef247c8 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -1,43 +1,51 @@ 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 _cardDetail = MutableStateFlow(null) - val cardDetail: StateFlow = _cardDetail.asStateFlow() + private val _syncing = MutableStateFlow(false) + val syncing: StateFlow = _syncing.asStateFlow() private val _phoneNotReachable = MutableStateFlow(false) val phoneNotReachable: StateFlow = _phoneNotReachable.asStateFlow() - fun reset() { - _cards.value = null - _phoneNotReachable.value = false + fun loadCache(context: Context) { + val cached = WearCardStore.load(context) + if (cached != null && _cards.value == null) { + _cards.value = cached + Log.d(TAG, "Loaded ${cached.size} cards from cache") + } + } + + fun setSyncing(value: Boolean) { + _syncing.value = value + if (value) _phoneNotReachable.value = false } fun setPhoneNotReachable() { - if (_cards.value == null) _phoneNotReachable.value = true + _syncing.value = false + _phoneNotReachable.value = true } - fun updateCards(json: String) { + fun updateCards(context: Context, json: String) { + _syncing.value = false _phoneNotReachable.value = false try { - _cards.value = WearCard.listFromJson(json) - android.util.Log.d("WearCardRepository", "Cards updated: ${_cards.value?.size} cards") + val incoming = WearCard.listFromJson(json) + _cards.value = incoming + WearCardStore.save(context, incoming) + Log.d(TAG, "Cards updated: ${incoming.size} cards") } catch (e: Exception) { - android.util.Log.e("WearCardRepository", "Failed to parse cards JSON", e) + Log.e(TAG, "Failed to parse cards JSON", e) } } - - fun updateCardDetail(json: String) { - _cardDetail.value = WearCard.fromJson(org.json.JSONObject(json)) - } - - fun clearCardDetail() { - _cardDetail.value = null - } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt new file mode 100644 index 0000000000..407a0a308c --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt @@ -0,0 +1,22 @@ +package me.hackerchick.catima.wear + +import android.content.Context + +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) + .apply() + } +} 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 index be2b20cf04..1b89249abb 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt @@ -14,6 +14,7 @@ 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 @@ -26,6 +27,7 @@ import me.hackerchick.catima.wear.WearCard @Composable fun CardListScreen( cards: List?, + syncing: Boolean, phoneNotReachable: Boolean, onCardClick: (WearCard) -> Unit, ) { @@ -46,7 +48,7 @@ fun CardListScreen( cards == null -> { CircularProgressIndicator() } - cards.isEmpty() -> { + cards.isEmpty() && !syncing -> { Text( text = stringResource(R.string.no_cards), textAlign = TextAlign.Center, @@ -76,6 +78,27 @@ fun CardListScreen( }, ) } + if (syncing) { + item { + Text( + text = stringResource(R.string.syncing), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (phoneNotReachable) { + item { + Text( + text = stringResource(R.string.sync_failed), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } } } } diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 02d68e7797..911264afc0 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -6,4 +6,6 @@ Loading cards… No barcode available for this card. Card ID + Syncing with phone… + Offline – showing cached cards From 7f08786bf29454a045ec1116b6a13b92af3bd2c8 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 2 Jul 2026 22:51:53 +0200 Subject: [PATCH 09/24] chore(build): Set debug applicationIdSuffix - Added again to the `app` build.gradle.kts - Added also to the `wear` build --- app/build.gradle.kts | 1 + wear/build.gradle.kts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5c745b723..c010928beb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,7 @@ android { ) } debug { + applicationIdSuffix = ".debug" } } diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index e99c606fda..378f2121b2 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -28,6 +28,10 @@ android { "proguard-rules.pro" ) } + + debug { + applicationIdSuffix = ".debug" + } } buildFeatures { From b7cbe83fac1df238847775f9764a5e246b073e55 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 15:32:50 +0200 Subject: [PATCH 10/24] fix: check BLUETOOTH_CONNECT permission at call site in AcceptThread Fixes failing Github Actions LINT job. --- .../protect/card_locker/BluetoothServerService.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/protect/card_locker/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/BluetoothServerService.kt index 6e3764a984..2c484ffab9 100644 --- a/app/src/main/java/protect/card_locker/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/BluetoothServerService.kt @@ -92,9 +92,19 @@ class BluetoothServerService : Service() { init { try { - serverSocket = adapter.listenUsingRfcommWithServiceRecord(BT_SERVICE_NAME, BT_SERVICE_UUID) + if (ContextCompat.checkSelfPermission( + this@BluetoothServerService, + android.Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + ) { + serverSocket = adapter.listenUsingRfcommWithServiceRecord(BT_SERVICE_NAME, BT_SERVICE_UUID) + } else { + Log.w(TAG, "BLUETOOTH_CONNECT permission missing, cannot open server socket") + running = false + } } catch (e: Exception) { Log.e(TAG, "Failed to create server socket", e) + running = false } } From 06c2fc5652cb3eb2ff0abdc641f787d91516a47c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 15:36:42 +0200 Subject: [PATCH 11/24] refactor: Centralise notification/channel IDs in NotificationInfo Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522845810 --- .../java/protect/card_locker/BluetoothServerService.kt | 4 ++-- app/src/main/java/protect/card_locker/NotificationInfo.kt | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/protect/card_locker/NotificationInfo.kt diff --git a/app/src/main/java/protect/card_locker/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/BluetoothServerService.kt index 2c484ffab9..71f5ac20e1 100644 --- a/app/src/main/java/protect/card_locker/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/BluetoothServerService.kt @@ -28,8 +28,8 @@ class BluetoothServerService : Service() { private const val BT_SERVICE_NAME = "CatimaWear" val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") private const val CMD_CARDS_REQUEST = "CARDS_REQUEST" - private const val NOTIFICATION_ID = 1001 - private const val CHANNEL_ID = "catima_wear_bt" + private val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID + private val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID } private var serverThread: AcceptThread? = null diff --git a/app/src/main/java/protect/card_locker/NotificationInfo.kt b/app/src/main/java/protect/card_locker/NotificationInfo.kt new file mode 100644 index 0000000000..dae1d1434b --- /dev/null +++ b/app/src/main/java/protect/card_locker/NotificationInfo.kt @@ -0,0 +1,8 @@ +package protect.card_locker + +object NotificationInfo { + object WearBluetooth { + const val NOTIFICATION_ID = 1001 + const val CHANNEL_ID = "catima_wear_bt" + } +} From 71bbb8629fda227b78cca7f6180dd003aa7dcd8b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 15:40:35 +0200 Subject: [PATCH 12/24] chore: Addressed LINT warning on rejected Bluetooth permission --- app/src/main/java/protect/card_locker/BluetoothServerService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/protect/card_locker/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/BluetoothServerService.kt index 71f5ac20e1..4a4bda92c1 100644 --- a/app/src/main/java/protect/card_locker/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/BluetoothServerService.kt @@ -127,7 +127,7 @@ class BluetoothServerService : Service() { } private fun handleConnection(socket: BluetoothSocket) { - val deviceName = try { socket.remoteDevice.name } catch (e: Exception) { "unknown" } + val deviceName = try { socket.remoteDevice.name } catch (_: SecurityException) { "unknown" } Log.d(TAG, "Connected to $deviceName") try { val reader = BufferedReader(InputStreamReader(socket.inputStream, Charsets.UTF_8)) From 75fd1f20cae9a1defa237d3734acff4d96f7d8a9 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 15:51:01 +0200 Subject: [PATCH 13/24] chore: Addressed LINT warnings in BluetoothServerService Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522850524 Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522858347 --- .../protect/card_locker/BluetoothServerService.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/protect/card_locker/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/BluetoothServerService.kt index 4a4bda92c1..c85ab0725c 100644 --- a/app/src/main/java/protect/card_locker/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/BluetoothServerService.kt @@ -18,6 +18,7 @@ import org.json.JSONArray import org.json.JSONObject import java.io.BufferedReader import java.io.InputStreamReader +import java.io.OutputStreamWriter import java.io.PrintWriter import java.util.UUID @@ -28,8 +29,8 @@ class BluetoothServerService : Service() { private const val BT_SERVICE_NAME = "CatimaWear" val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") private const val CMD_CARDS_REQUEST = "CARDS_REQUEST" - private val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID - private val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID + private const val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID + private const val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID } private var serverThread: AcceptThread? = null @@ -86,7 +87,7 @@ class BluetoothServerService : Service() { override fun onBind(intent: Intent?): IBinder? = null - private inner class AcceptThread(private val adapter: BluetoothAdapter) : Thread() { + private inner class AcceptThread(adapter: BluetoothAdapter) : Thread() { private var serverSocket: BluetoothServerSocket? = null @Volatile private var running = true @@ -130,8 +131,8 @@ class BluetoothServerService : Service() { val deviceName = try { socket.remoteDevice.name } catch (_: SecurityException) { "unknown" } Log.d(TAG, "Connected to $deviceName") try { - val reader = BufferedReader(InputStreamReader(socket.inputStream, Charsets.UTF_8)) - val writer = PrintWriter(socket.outputStream, false, Charsets.UTF_8) + 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") when (command) { @@ -146,7 +147,7 @@ class BluetoothServerService : Service() { } catch (e: Exception) { Log.e(TAG, "Error handling connection from $deviceName", e) } finally { - try { socket.close() } catch (e: Exception) { } + try { socket.close() } catch (_: Exception) { } } } @@ -192,7 +193,7 @@ class BluetoothServerService : Service() { fun cancel() { running = false - try { serverSocket?.close() } catch (e: Exception) { } + try { serverSocket?.close() } catch (_: Exception) { } } } } From 33a36ef6d82b935f5c58d6e49569171adefe75ae Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 17:02:10 +0200 Subject: [PATCH 14/24] feat(wear): Version the Bluetooth sync protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PROTOCOL_VERSION (1) to the phone↔watch Bluetooth protocol so both sides can detect an outdated companion app. - Phone: accept V1/CARDS_REQUEST and return {"version":1,"cards":[...]} - Phone: keep plain-array response for legacy CARDS_REQUEST - Wear: send V1/CARDS_REQUEST and parse the version envelope - Wear: display "phone outdated" / "watch outdated" messages Closes: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522862354 --- .../card_locker/BluetoothServerService.kt | 22 ++++++- .../catima/wear/BluetoothCardClient.kt | 61 +++++++++++++++---- .../hackerchick/catima/wear/MainActivity.kt | 33 +++++++--- .../catima/wear/WearCardRepository.kt | 30 ++++++++- .../hackerchick/catima/wear/WearProtocol.kt | 3 +- .../catima/wear/ui/CardListScreen.kt | 36 +++++++++++ wear/src/main/res/values/strings.xml | 2 + 7 files changed, 163 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/protect/card_locker/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/BluetoothServerService.kt index c85ab0725c..24a1d5fc98 100644 --- a/app/src/main/java/protect/card_locker/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/BluetoothServerService.kt @@ -28,7 +28,9 @@ class BluetoothServerService : Service() { private const val TAG = "CatimaBtServer" private const val BT_SERVICE_NAME = "CatimaWear" val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") + private const val PROTOCOL_VERSION = 1 private const val CMD_CARDS_REQUEST = "CARDS_REQUEST" + private const val CMD_CARDS_REQUEST_V1 = "V1/CARDS_REQUEST" private const val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID private const val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID } @@ -136,12 +138,19 @@ class BluetoothServerService : Service() { val command = reader.readLine()?.trim() Log.d(TAG, "Received command: $command from $deviceName") when (command) { - CMD_CARDS_REQUEST -> { + CMD_CARDS_REQUEST_V1 -> { val json = buildCardsJson() writer.println(json) writer.flush() Log.d(TAG, "Sent ${json.length} bytes to $deviceName") } + CMD_CARDS_REQUEST -> { + Log.w(TAG, "Wear companion app from $deviceName is using an old protocol; consider updating it") + val json = buildCardsJsonLegacy() + writer.println(json) + writer.flush() + Log.d(TAG, "Sent ${json.length} bytes (legacy format) to $deviceName") + } else -> Log.w(TAG, "Unknown command: $command") } } catch (e: Exception) { @@ -152,6 +161,15 @@ class BluetoothServerService : Service() { } private fun buildCardsJson(): String { + return JSONObject().apply { + put("version", PROTOCOL_VERSION) + put("cards", buildCardsArray()) + }.toString() + } + + private fun buildCardsJsonLegacy(): String = buildCardsArray().toString() + + private fun buildCardsArray(): JSONArray { val dbHelper = DBHelper(this@BluetoothServerService) val db = dbHelper.readableDatabase val cursor = db.query( @@ -188,7 +206,7 @@ class BluetoothServerService : Service() { }) } } - return array.toString() + return array } fun cancel() { diff --git a/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt index 60cccf76fb..d6ea0d8bd3 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt @@ -1,40 +1,49 @@ package me.hackerchick.catima.wear -import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothSocket import android.content.Context import android.content.pm.PackageManager import android.util.Log import androidx.core.content.ContextCompat +import org.json.JSONArray +import org.json.JSONObject import java.io.BufferedReader import java.io.InputStreamReader +import java.io.OutputStreamWriter import java.io.PrintWriter object BluetoothCardClient { private const val TAG = "CatimaBtClient" - fun fetchCards(context: Context, onResult: (cards: String?) -> Unit) { + enum class FetchStatus { + SUCCESS, + PHONE_OUTDATED, + WATCH_OUTDATED, + NO_DEVICE + } + + fun fetchCards(context: Context, onResult: (cards: String?, status: FetchStatus) -> Unit) { if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED ) { Log.w(TAG, "BLUETOOTH_CONNECT permission not granted") - onResult(null) + onResult(null, FetchStatus.NO_DEVICE) 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) + onResult(null, FetchStatus.NO_DEVICE) return } val bondedDevices = adapter.bondedDevices if (bondedDevices.isEmpty()) { Log.w(TAG, "No bonded devices found") - onResult(null) + onResult(null, FetchStatus.NO_DEVICE) return } @@ -42,8 +51,9 @@ object BluetoothCardClient { Thread { var result: String? = null + var status = FetchStatus.NO_DEVICE for (device in bondedDevices) { - val deviceName = try { device.name } catch (e: Exception) { "unknown" } + val deviceName = try { device.name } catch (_: Exception) { "unknown" } Log.d(TAG, "Trying $deviceName") var socket: BluetoothSocket? = null try { @@ -51,29 +61,54 @@ object BluetoothCardClient { socket.connect() Log.d(TAG, "Connected to $deviceName") - val writer = PrintWriter(socket.outputStream, false, Charsets.UTF_8) - val reader = BufferedReader(InputStreamReader(socket.inputStream, Charsets.UTF_8)) + val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false) + val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8")) - writer.print(WearProtocol.BT_CMD_CARDS_REQUEST) + writer.print(WearProtocol.BT_CMD_CARDS_REQUEST_V1) writer.flush() val json = reader.readLine()?.trim() ?: "" if (json.isNotEmpty()) { Log.d(TAG, "Received ${json.length} bytes from $deviceName") - result = json + val parsed = parseResponse(json) + result = parsed.first + status = parsed.second break } } catch (e: Exception) { Log.d(TAG, "Failed to connect to $deviceName: ${e.message}") } finally { - try { socket?.close() } catch (e: Exception) { } + try { socket?.close() } catch (_: Exception) { } } } - if (result == null) { + if (status == FetchStatus.NO_DEVICE) { Log.w(TAG, "No Catima phone found among bonded devices") } - onResult(result) + onResult(result, status) }.start() } + + private fun parseResponse(json: String): Pair { + return try { + val obj = JSONObject(json) + val version = obj.getInt("version") + if (version != WearProtocol.PROTOCOL_VERSION) { + Log.w(TAG, "Unsupported protocol version: $version") + null to FetchStatus.WATCH_OUTDATED + } else { + val cards = obj.getJSONArray("cards").toString() + cards to FetchStatus.SUCCESS + } + } catch (_: Exception) { + try { + JSONArray(json) + Log.w(TAG, "Phone app returned a legacy response; update Catima on your phone") + null to FetchStatus.PHONE_OUTDATED + } catch (_: Exception) { + Log.w(TAG, "Unparseable response from phone") + null to FetchStatus.NO_DEVICE + } + } + } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index 6e2422242b..c5d28851ba 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -45,6 +45,8 @@ class MainActivity : ComponentActivity() { val cards by WearCardRepository.cards.collectAsState() val syncing by WearCardRepository.syncing.collectAsState() val phoneNotReachable by WearCardRepository.phoneNotReachable.collectAsState() + val phoneOutdated by WearCardRepository.phoneOutdated.collectAsState() + val watchOutdated by WearCardRepository.watchOutdated.collectAsState() SwipeDismissableNavHost( navController = navController, @@ -55,6 +57,8 @@ class MainActivity : ComponentActivity() { cards = cards, syncing = syncing, phoneNotReachable = phoneNotReachable, + phoneOutdated = phoneOutdated, + watchOutdated = watchOutdated, onCardClick = { card -> navController.navigate("card_view/${card.id}") } @@ -93,14 +97,29 @@ class MainActivity : ComponentActivity() { private fun requestCardsFromPhone() { fetchInFlight = true WearCardRepository.setSyncing(true) - BluetoothCardClient.fetchCards(this) { json -> + BluetoothCardClient.fetchCards(this) { json, status -> fetchInFlight = false - if (json != null) { - Log.d(TAG, "Got cards via Bluetooth") - WearCardRepository.updateCards(this, json) - } else { - Log.w(TAG, "Bluetooth failed, phone not reachable") - WearCardRepository.setPhoneNotReachable() + when (status) { + BluetoothCardClient.FetchStatus.SUCCESS -> { + if (json != null) { + Log.d(TAG, "Got cards via Bluetooth") + WearCardRepository.updateCards(this, json) + } else { + WearCardRepository.setPhoneNotReachable() + } + } + BluetoothCardClient.FetchStatus.PHONE_OUTDATED -> { + Log.w(TAG, "Phone app is outdated") + WearCardRepository.setPhoneOutdated() + } + BluetoothCardClient.FetchStatus.WATCH_OUTDATED -> { + Log.w(TAG, "Wear app is outdated") + WearCardRepository.setWatchOutdated() + } + BluetoothCardClient.FetchStatus.NO_DEVICE -> { + Log.w(TAG, "Bluetooth failed, phone not reachable") + WearCardRepository.setPhoneNotReachable() + } } } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt index 8fbef247c8..180fa50e2e 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -18,6 +18,12 @@ object WearCardRepository { private val _phoneNotReachable = MutableStateFlow(false) val phoneNotReachable: StateFlow = _phoneNotReachable.asStateFlow() + private val _phoneOutdated = MutableStateFlow(false) + val phoneOutdated: StateFlow = _phoneOutdated.asStateFlow() + + private val _watchOutdated = MutableStateFlow(false) + val watchOutdated: StateFlow = _watchOutdated.asStateFlow() + fun loadCache(context: Context) { val cached = WearCardStore.load(context) if (cached != null && _cards.value == null) { @@ -28,17 +34,39 @@ object WearCardRepository { fun setSyncing(value: Boolean) { _syncing.value = value - if (value) _phoneNotReachable.value = false + if (value) { + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + } } fun setPhoneNotReachable() { _syncing.value = false _phoneNotReachable.value = true + _phoneOutdated.value = false + _watchOutdated.value = false + } + + fun setPhoneOutdated() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = true + _watchOutdated.value = false + } + + fun setWatchOutdated() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = true } fun updateCards(context: Context, json: String) { _syncing.value = false _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false try { val incoming = WearCard.listFromJson(json) _cards.value = incoming diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt index dd67d7ab28..40a15fa2b5 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt @@ -4,5 +4,6 @@ import java.util.UUID object WearProtocol { val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") - const val BT_CMD_CARDS_REQUEST = "CARDS_REQUEST\n" + const val PROTOCOL_VERSION = 1 + const val BT_CMD_CARDS_REQUEST_V1 = "V1/CARDS_REQUEST\n" } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt index 1b89249abb..73b02cbc19 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt @@ -29,6 +29,8 @@ fun CardListScreen( cards: List?, syncing: Boolean, phoneNotReachable: Boolean, + phoneOutdated: Boolean, + watchOutdated: Boolean, onCardClick: (WearCard) -> Unit, ) { Box( @@ -45,6 +47,20 @@ fun CardListScreen( modifier = Modifier.padding(16.dp), ) } + cards == null && phoneOutdated -> { + Text( + text = stringResource(R.string.phone_outdated), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && watchOutdated -> { + Text( + text = stringResource(R.string.watch_outdated), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } cards == null -> { CircularProgressIndicator() } @@ -98,6 +114,26 @@ fun CardListScreen( modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) } + } else if (phoneOutdated) { + item { + Text( + text = stringResource(R.string.phone_outdated), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (watchOutdated) { + item { + Text( + text = stringResource(R.string.watch_outdated), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } } } } diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 911264afc0..da2ce6cfc1 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -8,4 +8,6 @@ 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. From 6b769c6259d32b7ed6ad9b82caed490265fcb301 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 17:07:33 +0200 Subject: [PATCH 15/24] refactor(bluetooth): Move BluetoothServerService to wearos package Closes: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522868889 --- app/src/main/AndroidManifest.xml | 2 +- .../protect/card_locker/LoyaltyCardLockerApplication.java | 1 + app/src/main/java/protect/card_locker/MainActivity.kt | 1 + .../card_locker/{ => wearos}/BluetoothServerService.kt | 5 ++++- 4 files changed, 7 insertions(+), 2 deletions(-) rename app/src/main/java/protect/card_locker/{ => wearos}/BluetoothServerService.kt (98%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 63b5bfa79b..ec2004a7be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -230,7 +230,7 @@ diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java index 8a2c7f39ba..f579d04ea0 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java @@ -12,6 +12,7 @@ import org.acra.data.StringFormat; import protect.card_locker.preferences.Settings; +import protect.card_locker.wearos.BluetoothServerService; public class LoyaltyCardLockerApplication extends Application { diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index 66acc53c4b..837200f378 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -39,6 +39,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.BluetoothServerService import java.io.UnsupportedEncodingException import java.util.concurrent.atomic.AtomicInteger import androidx.core.content.edit diff --git a/app/src/main/java/protect/card_locker/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt similarity index 98% rename from app/src/main/java/protect/card_locker/BluetoothServerService.kt rename to app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt index 24a1d5fc98..9de967d181 100644 --- a/app/src/main/java/protect/card_locker/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt @@ -1,4 +1,4 @@ -package protect.card_locker +package protect.card_locker.wearos import android.app.NotificationChannel import android.app.NotificationManager @@ -16,6 +16,9 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import org.json.JSONArray import org.json.JSONObject +import protect.card_locker.DBHelper +import protect.card_locker.NotificationInfo +import protect.card_locker.R import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStreamWriter From 5778d3eecf627ca3be90f1b609eb8d8f32f81ba9 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 4 Jul 2026 22:24:29 +0200 Subject: [PATCH 16/24] feat(app): Add Wear OS sync setting and gate Bluetooth server Add a "Sync with Wear OS" toggle in settings and only start the Bluetooth companion server when the setting is enabled and the BLUETOOTH_CONNECT permission is granted. - Add Wear OS preference category and toggle - Gate service start in LoyaltyCardLockerApplication - Remove redundant direct startService in MainActivity.onCreate - Keep permission-callback start for first-time grant Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522870665 --- .../LoyaltyCardLockerApplication.java | 10 ++++++++-- .../java/protect/card_locker/MainActivity.kt | 18 +++++++++--------- .../card_locker/preferences/Settings.java | 4 ++++ app/src/main/res/values/strings.xml | 4 ++++ app/src/main/res/xml/preferences.xml | 14 ++++++++++++++ 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java index f579d04ea0..fbf2ff1bda 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java @@ -4,6 +4,7 @@ import android.content.Intent; import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; import org.acra.ACRA; import org.acra.config.CoreConfigurationBuilder; @@ -44,7 +45,12 @@ public void onCreate() { Settings settings = new Settings(this); AppCompatDelegate.setDefaultNightMode(settings.getTheme()); - // Start Bluetooth server for Wear OS companion - startService(new Intent(this, BluetoothServerService.class)); + // Start Bluetooth server for Wear OS companion if enabled + if (settings.getWearSyncEnabled() && + ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) + == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + startService(new Intent(this, BluetoothServerService.class)); + } } } diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index 837200f378..7a4e2571d1 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -68,7 +68,9 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { private val mBtPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> - if (granted) startService(Intent(this, BluetoothServerService::class.java)) + if (granted && Settings(this).wearSyncEnabled) { + startService(Intent(this, BluetoothServerService::class.java)) + } } private val mCurrentActionModeCallback: ActionMode.Callback = object : ActionMode.Callback { @@ -257,14 +259,12 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { }.start() // Request Bluetooth permission for Wear OS companion (Android 12+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) - != android.content.pm.PackageManager.PERMISSION_GRANTED - ) { - mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) - } else { - startService(Intent(this, BluetoothServerService::class.java)) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + Settings(this).wearSyncEnabled && + ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) + != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) } // We should extract the share intent after we called the super.onCreate as it may need to spawn a dialog window and the app needs to be initialized to not crash diff --git a/app/src/main/java/protect/card_locker/preferences/Settings.java b/app/src/main/java/protect/card_locker/preferences/Settings.java index f77f4b359f..1cf313119d 100644 --- a/app/src/main/java/protect/card_locker/preferences/Settings.java +++ b/app/src/main/java/protect/card_locker/preferences/Settings.java @@ -112,4 +112,8 @@ public int getPreferredColumnCount() { public boolean useVolumeKeysForNavigation() { return getBoolean(R.string.settings_key_use_volume_keys_navigation, false); } + + public boolean getWearSyncEnabled() { + return getBoolean(R.string.settings_key_wear_sync, true); + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a4d0092ca..1d17ab4895 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,6 +302,10 @@ pref_column_count_landscape General Privacy + Wear OS + pref_wear_sync + Sync with Wear OS + Allow a Wear OS companion app to read your cards over Bluetooth Display options Show archived cards View online diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index fec2bce50f..ef3263122a 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -134,4 +134,18 @@ app:singleLineTitle="false" /> + + + + + From f4a45bcddcab3f459477f2f7a4b018369317ab8e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 11:11:40 +0200 Subject: [PATCH 17/24] refactor(bt-service): Remove pointless compatibility layer with previous protocol. Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3523428268 --- .../wearos/BluetoothServerService.kt | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt index 9de967d181..7b4d6fc011 100644 --- a/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt @@ -32,8 +32,7 @@ class BluetoothServerService : Service() { private const val BT_SERVICE_NAME = "CatimaWear" val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") private const val PROTOCOL_VERSION = 1 - private const val CMD_CARDS_REQUEST = "CARDS_REQUEST" - private const val CMD_CARDS_REQUEST_V1 = "V1/CARDS_REQUEST" + private const val CMD_V1_CARDS_REQUEST = "V1/CARDS_REQUEST" private const val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID private const val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID } @@ -140,21 +139,18 @@ class BluetoothServerService : Service() { val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false) val command = reader.readLine()?.trim() Log.d(TAG, "Received command: $command from $deviceName") - when (command) { - CMD_CARDS_REQUEST_V1 -> { - val json = buildCardsJson() - writer.println(json) - writer.flush() - Log.d(TAG, "Sent ${json.length} bytes to $deviceName") + if (command != null && command.startsWith("V1/")) { + when (command) { + CMD_V1_CARDS_REQUEST -> { + val json = buildCardsJson() + writer.println(json) + writer.flush() + Log.d(TAG, "Sent ${json.length} bytes to $deviceName") + } + else -> Log.w(TAG, "Unsupported V1 command: $command from $deviceName") } - CMD_CARDS_REQUEST -> { - Log.w(TAG, "Wear companion app from $deviceName is using an old protocol; consider updating it") - val json = buildCardsJsonLegacy() - writer.println(json) - writer.flush() - Log.d(TAG, "Sent ${json.length} bytes (legacy format) to $deviceName") - } - else -> Log.w(TAG, "Unknown command: $command") + } else { + Log.w(TAG, "Unsupported protocol version for command: $command from $deviceName") } } catch (e: Exception) { Log.e(TAG, "Error handling connection from $deviceName", e) @@ -170,8 +166,6 @@ class BluetoothServerService : Service() { }.toString() } - private fun buildCardsJsonLegacy(): String = buildCardsArray().toString() - private fun buildCardsArray(): JSONArray { val dbHelper = DBHelper(this@BluetoothServerService) val db = dbHelper.readableDatabase From 4fca884bab2081e15cc4da236d96057a6d776ddf Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 11:41:16 +0200 Subject: [PATCH 18/24] feat(wear): Apply the same sorting as the phone app. Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522867597 --- .../wearos/BluetoothServerService.kt | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt index 7b4d6fc011..4e843cba84 100644 --- a/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt +++ b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt @@ -19,6 +19,7 @@ import org.json.JSONObject import protect.card_locker.DBHelper import protect.card_locker.NotificationInfo import protect.card_locker.R +import protect.card_locker.Utils import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStreamWriter @@ -169,20 +170,11 @@ class BluetoothServerService : Service() { private fun buildCardsArray(): JSONArray { val dbHelper = DBHelper(this@BluetoothServerService) val db = dbHelper.readableDatabase - val cursor = db.query( - DBHelper.LoyaltyCardDbIds.TABLE, - arrayOf( - DBHelper.LoyaltyCardDbIds.ID, - DBHelper.LoyaltyCardDbIds.STORE, - DBHelper.LoyaltyCardDbIds.CARD_ID, - DBHelper.LoyaltyCardDbIds.BARCODE_ID, - DBHelper.LoyaltyCardDbIds.BARCODE_TYPE, - DBHelper.LoyaltyCardDbIds.HEADER_COLOR, - DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS, - ), - "${DBHelper.LoyaltyCardDbIds.ARCHIVE_STATUS} = 0", - null, null, null, - "${DBHelper.LoyaltyCardDbIds.STORE} COLLATE NOCASE ASC" + val order = Utils.getLoyaltyCardOrder(this@BluetoothServerService) + val orderDirection = Utils.getLoyaltyCardOrderDirection(this@BluetoothServerService) + val cursor = DBHelper.getLoyaltyCardCursor( + db, "", null, order, orderDirection, + DBHelper.LoyaltyCardArchiveFilter.Unarchived ) val array = JSONArray() cursor.use { From c3511d8c23e6f424c6b8107af503e2bab375a4b5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 11:52:53 +0200 Subject: [PATCH 19/24] feat(wear): Default Wear OS sync to off; move permission flow to Settings - Default pref_wear_sync to false (opt-in rather than opt-out) - Remove BT permission request from MainActivity; it now lives exclusively in SettingsFragment where the toggle is visible and context is clear - Request BLUETOOTH_CONNECT immediately when the toggle is turned on - Start/stop BluetoothServerService reactively on toggle change - Re-request permission on SettingsFragment resume to handle cases where Android revoked it while the setting was still enabled - Simplify processParseResultList() by removing unused group/closeApp params Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3524970181 --- .../java/protect/card_locker/MainActivity.kt | 35 ++----------- .../card_locker/preferences/Settings.java | 2 +- .../preferences/SettingsActivity.kt | 52 +++++++++++++++++-- app/src/main/res/xml/preferences.xml | 2 +- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index 7a4e2571d1..0a1ad374d4 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -20,9 +20,7 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.ContextCompat import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -39,7 +37,6 @@ 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.BluetoothServerService import java.io.UnsupportedEncodingException import java.util.concurrent.atomic.AtomicInteger import androidx.core.content.edit @@ -65,14 +62,6 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { private lateinit var mBarcodeScannerLauncher: ActivityResultLauncher private lateinit var mSettingsLauncher: ActivityResultLauncher - private val mBtPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted && Settings(this).wearSyncEnabled) { - startService(Intent(this, BluetoothServerService::class.java)) - } - } - private val mCurrentActionModeCallback: ActionMode.Callback = object : ActionMode.Callback { override fun onCreateActionMode(inputMode: ActionMode, inputMenu: Menu?): Boolean { inputMode.menuInflater.inflate(R.menu.card_longclick_menu, inputMenu) @@ -258,15 +247,6 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { } }.start() - // Request Bluetooth permission for Wear OS companion (Android 12+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - Settings(this).wearSyncEnabled && - ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) - != android.content.pm.PackageManager.PERMISSION_GRANTED - ) { - mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) - } - // We should extract the share intent after we called the super.onCreate as it may need to spawn a dialog window and the app needs to be initialized to not crash extractIntentFields(intent) @@ -487,11 +467,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { ShortcutHelper.updateShortcuts(mAdapter.mContext) } - private fun processParseResultList( - parseResultList: MutableList, - group: String?, - closeAppOnNoBarcode: Boolean - ) { + private fun processParseResultList(parseResultList: MutableList) { require(!parseResultList.isEmpty()) { "parseResultList may not be empty" } Utils.makeUserChooseParseResultFromList( @@ -502,17 +478,12 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { val intent = Intent(applicationContext, LoyaltyCardEditActivity::class.java) val bundle = parseResult.toLoyaltyCardBundle(this@MainActivity) - if (group != null) { - bundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, group) - } intent.putExtras(bundle) startActivity(intent) } override fun onUserDismissedSelector() { - if (closeAppOnNoBarcode) { - finish() - } + finish() } }) } @@ -576,7 +547,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { return } - processParseResultList(parseResultList, null, true) + processParseResultList(parseResultList) } private fun extractIntentFields(intent: Intent) { diff --git a/app/src/main/java/protect/card_locker/preferences/Settings.java b/app/src/main/java/protect/card_locker/preferences/Settings.java index 1cf313119d..39014cec3c 100644 --- a/app/src/main/java/protect/card_locker/preferences/Settings.java +++ b/app/src/main/java/protect/card_locker/preferences/Settings.java @@ -114,6 +114,6 @@ public boolean useVolumeKeysForNavigation() { } public boolean getWearSyncEnabled() { - return getBoolean(R.string.settings_key_wear_sync, true); + return getBoolean(R.string.settings_key_wear_sync, false); } } diff --git a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt index 8d48320d22..dd5c72e2d7 100644 --- a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt +++ b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt @@ -1,22 +1,26 @@ package protect.card_locker.preferences -import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.MenuItem import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat import protect.card_locker.BuildConfig import protect.card_locker.CatimaAppCompatActivity import protect.card_locker.MainActivity import protect.card_locker.R import protect.card_locker.Utils import protect.card_locker.databinding.SettingsActivityBinding +import protect.card_locker.wearos.BluetoothServerService class SettingsActivity : CatimaAppCompatActivity() { @@ -81,6 +85,25 @@ class SettingsActivity : CatimaAppCompatActivity() { class SettingsFragment : PreferenceFragmentCompat() { var mReloadMain: Boolean = false + private val mBtPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted && Settings(requireContext()).wearSyncEnabled) { + requireContext().startService(Intent(requireContext(), BluetoothServerService::class.java)) + } + } + + override fun onResume() { + super.onResume() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + Settings(requireContext()).wearSyncEnabled && + ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) + } + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { // Load the preferences from an XML resource addPreferencesFromResource(R.xml.preferences) @@ -104,7 +127,7 @@ class SettingsActivity : CatimaAppCompatActivity() { val oledDarkPreference = findPreference(getString(R.string.settings_key_oled_dark)) oledDarkPreference!!.setOnPreferenceChangeListener { _, _ -> - refreshActivity(true) + refreshActivity() true } @@ -148,7 +171,7 @@ class SettingsActivity : CatimaAppCompatActivity() { localePreference.setOnPreferenceChangeListener { _, newValue -> // See corresponding comment in Utils.updateBaseContextLocale for Android 6- notes if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - refreshActivity(true) + refreshActivity() return@setOnPreferenceChangeListener true } val newLocale = newValue as String @@ -157,13 +180,32 @@ class SettingsActivity : CatimaAppCompatActivity() { true } + val wearSyncPreference = findPreference(getString(R.string.settings_key_wear_sync)) + wearSyncPreference!!.setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + val ctx = requireContext() + if (enabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) + } else { + ctx.startService(Intent(ctx, BluetoothServerService::class.java)) + } + } else { + ctx.stopService(Intent(ctx, BluetoothServerService::class.java)) + } + true + } + // Hide crash reporter settings on builds it's not enabled on val crashReporterPreference = findPreference("acra.enable") crashReporterPreference!!.isVisible = BuildConfig.useAcraCrashReporter } - private fun refreshActivity(reloadMain: Boolean) { - mReloadMain = reloadMain || mReloadMain + private fun refreshActivity() { + mReloadMain = true activity?.recreate() } } diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index ef3263122a..37d407c492 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -140,7 +140,7 @@ Date: Mon, 6 Jul 2026 12:49:31 +0200 Subject: [PATCH 20/24] fix(wear): Use LocalActivity instead of LocalContext cast in CardViewScreen - Replace unsafe `LocalContext.current as? Activity` with `LocalActivity.current` in `KeepScreenOnAtMaxBrightness`. - Refactor `generateBarcode` to return a `BarcodeResult` carrying both the bitmap and `isSquare` flag, removing the redundant `BarcodeFormat` lookup in the composable. Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522928602 --- .../catima/wear/ui/CardViewScreen.kt | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) 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 index 68229dab1f..630c67b7ef 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt @@ -1,6 +1,5 @@ package me.hackerchick.catima.wear.ui -import android.app.Activity import android.graphics.Bitmap import android.graphics.Color import android.view.WindowManager @@ -22,7 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext +import androidx.activity.compose.LocalActivity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -51,7 +50,7 @@ fun CardViewScreen(card: WearCard?) { @Composable private fun KeepScreenOnAtMaxBrightness() { - val activity = LocalContext.current as? Activity ?: return + val activity = LocalActivity.current ?: return DisposableEffect(Unit) { val window = activity.window window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) @@ -101,20 +100,15 @@ private fun CardDetail(card: WearCard) { Spacer(modifier = Modifier.height(8.dp)) if (barcodeType != null) { - val barcodeBitmap = remember(barcodeValue, barcodeType) { + val barcodeResult = remember(barcodeValue, barcodeType) { generateBarcode(barcodeValue, barcodeType) } - if (barcodeBitmap != null) { - val format = runCatching { BarcodeFormat.valueOf(barcodeType) }.getOrNull() - val isSquare = format == BarcodeFormat.QR_CODE - || format == BarcodeFormat.AZTEC - || format == BarcodeFormat.DATA_MATRIX - + if (barcodeResult != null) { Image( - bitmap = barcodeBitmap.asImageBitmap(), + bitmap = barcodeResult.bitmap.asImageBitmap(), contentDescription = card.cardId, - modifier = if (isSquare) { + modifier = if (barcodeResult.isSquare) { Modifier.size(barcodeMaxSize) } else { Modifier.fillMaxWidth(0.85f).height(60.dp) @@ -147,12 +141,17 @@ private fun CardDetail(card: WearCard) { } } -private fun generateBarcode(value: String, formatName: String): Bitmap? { +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 = format == BarcodeFormat.QR_CODE - || format == BarcodeFormat.AZTEC - || format == BarcodeFormat.DATA_MATRIX + val isSquare = isBarcodeSquare(format) val width = if (isSquare) 300 else 600 val height = if (isSquare) 300 else 150 @@ -166,8 +165,8 @@ private fun generateBarcode(value: String, formatName: String): Bitmap? { if (bitMatrix[x, y]) Color.BLACK else Color.WHITE } - Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) - } catch (e: Exception) { + BarcodeResult(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888), isSquare) + } catch (_: Exception) { null } } From c53e05db0ccf4ad8afd091eaf22fdabb5c43a767 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 12:58:18 +0200 Subject: [PATCH 21/24] feat(wear): Surface distinct Bluetooth error states to the user Previously, permission denial, Bluetooth being disabled, and no paired device all collapsed into a single NO_DEVICE status, showing a generic "could not reach phone" message. Users who denied the Bluetooth permission had no way to know what went wrong. Add PERMISSION_DENIED and BLUETOOTH_DISABLED to BluetoothCardClient.FetchStatus and thread them through WearCardRepository, MainActivity, and CardListScreen, showing targeted messages in both the full-screen (no cached cards) and footer (cached cards present) states. Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522930980 --- .../catima/wear/BluetoothCardClient.kt | 8 +++-- .../hackerchick/catima/wear/MainActivity.kt | 12 +++++++ .../catima/wear/WearCardRepository.kt | 34 ++++++++++++++++++ .../catima/wear/ui/CardListScreen.kt | 36 +++++++++++++++++++ wear/src/main/res/values/strings.xml | 2 ++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt index d6ea0d8bd3..3284772268 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt @@ -21,7 +21,9 @@ object BluetoothCardClient { SUCCESS, PHONE_OUTDATED, WATCH_OUTDATED, - NO_DEVICE + NO_DEVICE, + PERMISSION_DENIED, + BLUETOOTH_DISABLED } fun fetchCards(context: Context, onResult: (cards: String?, status: FetchStatus) -> Unit) { @@ -29,14 +31,14 @@ object BluetoothCardClient { != PackageManager.PERMISSION_GRANTED ) { Log.w(TAG, "BLUETOOTH_CONNECT permission not granted") - onResult(null, FetchStatus.NO_DEVICE) + onResult(null, FetchStatus.PERMISSION_DENIED) return } val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter if (adapter == null || !adapter.isEnabled) { Log.w(TAG, "Bluetooth not available or disabled") - onResult(null, FetchStatus.NO_DEVICE) + onResult(null, FetchStatus.BLUETOOTH_DISABLED) return } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index c5d28851ba..bee70ba582 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -47,6 +47,8 @@ class MainActivity : ComponentActivity() { val phoneNotReachable by WearCardRepository.phoneNotReachable.collectAsState() val phoneOutdated by WearCardRepository.phoneOutdated.collectAsState() val watchOutdated by WearCardRepository.watchOutdated.collectAsState() + val permissionDenied by WearCardRepository.permissionDenied.collectAsState() + val bluetoothDisabled by WearCardRepository.bluetoothDisabled.collectAsState() SwipeDismissableNavHost( navController = navController, @@ -59,6 +61,8 @@ class MainActivity : ComponentActivity() { phoneNotReachable = phoneNotReachable, phoneOutdated = phoneOutdated, watchOutdated = watchOutdated, + permissionDenied = permissionDenied, + bluetoothDisabled = bluetoothDisabled, onCardClick = { card -> navController.navigate("card_view/${card.id}") } @@ -120,6 +124,14 @@ class MainActivity : ComponentActivity() { Log.w(TAG, "Bluetooth failed, phone not reachable") WearCardRepository.setPhoneNotReachable() } + BluetoothCardClient.FetchStatus.PERMISSION_DENIED -> { + Log.w(TAG, "Bluetooth permission denied") + WearCardRepository.setPermissionDenied() + } + BluetoothCardClient.FetchStatus.BLUETOOTH_DISABLED -> { + Log.w(TAG, "Bluetooth is disabled") + WearCardRepository.setBluetoothDisabled() + } } } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt index 180fa50e2e..da0c9024db 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -24,6 +24,12 @@ object WearCardRepository { private val _watchOutdated = MutableStateFlow(false) val watchOutdated: StateFlow = _watchOutdated.asStateFlow() + private val _permissionDenied = MutableStateFlow(false) + val permissionDenied: StateFlow = _permissionDenied.asStateFlow() + + private val _bluetoothDisabled = MutableStateFlow(false) + val bluetoothDisabled: StateFlow = _bluetoothDisabled.asStateFlow() + fun loadCache(context: Context) { val cached = WearCardStore.load(context) if (cached != null && _cards.value == null) { @@ -38,6 +44,8 @@ object WearCardRepository { _phoneNotReachable.value = false _phoneOutdated.value = false _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false } } @@ -46,6 +54,8 @@ object WearCardRepository { _phoneNotReachable.value = true _phoneOutdated.value = false _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false } fun setPhoneOutdated() { @@ -53,6 +63,8 @@ object WearCardRepository { _phoneNotReachable.value = false _phoneOutdated.value = true _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false } fun setWatchOutdated() { @@ -60,6 +72,26 @@ object WearCardRepository { _phoneNotReachable.value = false _phoneOutdated.value = false _watchOutdated.value = true + _permissionDenied.value = false + _bluetoothDisabled.value = false + } + + fun setPermissionDenied() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = true + _bluetoothDisabled.value = false + } + + fun setBluetoothDisabled() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = true } fun updateCards(context: Context, json: String) { @@ -67,6 +99,8 @@ object WearCardRepository { _phoneNotReachable.value = false _phoneOutdated.value = false _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false try { val incoming = WearCard.listFromJson(json) _cards.value = incoming 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 index 73b02cbc19..50bcbfdb1e 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt @@ -31,6 +31,8 @@ fun CardListScreen( phoneNotReachable: Boolean, phoneOutdated: Boolean, watchOutdated: Boolean, + permissionDenied: Boolean, + bluetoothDisabled: Boolean, onCardClick: (WearCard) -> Unit, ) { Box( @@ -40,6 +42,20 @@ fun CardListScreen( contentAlignment = Alignment.Center, ) { when { + cards == null && permissionDenied -> { + Text( + text = stringResource(R.string.bluetooth_permission_denied), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && bluetoothDisabled -> { + Text( + text = stringResource(R.string.bluetooth_disabled), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } cards == null && phoneNotReachable -> { Text( text = stringResource(R.string.phone_not_connected), @@ -104,6 +120,26 @@ fun CardListScreen( modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) } + } else if (permissionDenied) { + item { + Text( + text = stringResource(R.string.bluetooth_permission_denied), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (bluetoothDisabled) { + item { + Text( + text = stringResource(R.string.bluetooth_disabled), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } } else if (phoneNotReachable) { item { Text( diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index da2ce6cfc1..b2ed630adb 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -10,4 +10,6 @@ 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. From 0a4374adbf635236f17876849cb7e576a6a51640 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 13:11:17 +0200 Subject: [PATCH 22/24] fix(wear): surface card parse failures to the user instead of silently swallowing them A JSON parse error in WearCardRepository.updateCards was only logged, leaving the UI with no indication that the sync had failed. Add a syncError StateFlow that is set on parse failure and cleared on a successful update or new sync attempt, then thread it through MainActivity and CardListScreen with a targeted message in both the full-screen and footer states. Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522938925 --- .../me/hackerchick/catima/wear/MainActivity.kt | 2 ++ .../catima/wear/WearCardRepository.kt | 6 ++++++ .../catima/wear/ui/CardListScreen.kt | 18 ++++++++++++++++++ wear/src/main/res/values/strings.xml | 1 + 4 files changed, 27 insertions(+) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt index bee70ba582..244638325b 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -49,6 +49,7 @@ class MainActivity : ComponentActivity() { val watchOutdated by WearCardRepository.watchOutdated.collectAsState() val permissionDenied by WearCardRepository.permissionDenied.collectAsState() val bluetoothDisabled by WearCardRepository.bluetoothDisabled.collectAsState() + val syncError by WearCardRepository.syncError.collectAsState() SwipeDismissableNavHost( navController = navController, @@ -63,6 +64,7 @@ class MainActivity : ComponentActivity() { watchOutdated = watchOutdated, permissionDenied = permissionDenied, bluetoothDisabled = bluetoothDisabled, + syncError = syncError, onCardClick = { card -> navController.navigate("card_view/${card.id}") } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt index da0c9024db..6e560048c6 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -30,6 +30,9 @@ object WearCardRepository { private val _bluetoothDisabled = MutableStateFlow(false) val bluetoothDisabled: StateFlow = _bluetoothDisabled.asStateFlow() + private val _syncError = MutableStateFlow(false) + val syncError: StateFlow = _syncError.asStateFlow() + fun loadCache(context: Context) { val cached = WearCardStore.load(context) if (cached != null && _cards.value == null) { @@ -46,6 +49,7 @@ object WearCardRepository { _watchOutdated.value = false _permissionDenied.value = false _bluetoothDisabled.value = false + _syncError.value = false } } @@ -104,10 +108,12 @@ object WearCardRepository { try { val incoming = WearCard.listFromJson(json) _cards.value = incoming + _syncError.value = false WearCardStore.save(context, incoming) Log.d(TAG, "Cards updated: ${incoming.size} cards") } catch (e: Exception) { Log.e(TAG, "Failed to parse cards JSON", e) + _syncError.value = true } } } diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt index 50bcbfdb1e..a4696fee85 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt @@ -33,6 +33,7 @@ fun CardListScreen( watchOutdated: Boolean, permissionDenied: Boolean, bluetoothDisabled: Boolean, + syncError: Boolean, onCardClick: (WearCard) -> Unit, ) { Box( @@ -42,6 +43,13 @@ fun CardListScreen( contentAlignment = Alignment.Center, ) { when { + cards == null && syncError -> { + Text( + text = stringResource(R.string.sync_error), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } cards == null && permissionDenied -> { Text( text = stringResource(R.string.bluetooth_permission_denied), @@ -160,6 +168,16 @@ fun CardListScreen( modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) } + } else if (syncError) { + item { + Text( + text = stringResource(R.string.sync_error), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } } else if (watchOutdated) { item { Text( diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index b2ed630adb..3d7b397c0f 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -12,4 +12,5 @@ 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. From eb155f9e23c5ec6ec84665aabd2134fb4dbb832f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 14:23:50 +0200 Subject: [PATCH 23/24] chore(wear): Replaced deprecated SharedPreferences usage Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522943122 --- .../main/java/me/hackerchick/catima/wear/WearCardStore.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt index 407a0a308c..1be564a45b 100644 --- a/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt @@ -1,6 +1,7 @@ package me.hackerchick.catima.wear import android.content.Context +import androidx.core.content.edit object WearCardStore { private const val PREFS_NAME = "catima_wear_cards" @@ -15,8 +16,8 @@ object WearCardStore { fun save(context: Context, cards: List) { val json = WearCard.listToJson(cards) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putString(KEY_CARDS_JSON, json) - .apply() + .edit { + putString(KEY_CARDS_JSON, json) + } } } From fc1c43dda77a3496b09f1586e0e2be0e777311ec Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 6 Jul 2026 14:27:04 +0200 Subject: [PATCH 24/24] chore(gitignore): Ignore /wear/release Addresses: https://github.com/CatimaLoyalty/Android/pull/3185#discussion_r3522945460 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 39f4b560ee..efd2864137 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ /app/build /app/release /wear/build +/wear/release /.idea/* !/.idea/icon.svg # Bundle