Add WearOS companion app#3185
Conversation
- 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
- 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
|
As written in the issues, WearOS requires proprietary dependencies. So this must be at very least be excluded from the foss flavour. That is a blocker, because F-Droid does not allow proprietary dependencies (and I don't want people to be forced to run proprietary Google code to run Catima, there must always be an option free is proprietary code) Aside from that, cool :) I don't have a WearOS device but I will look at this in an emulator soon. Are you willing to keep maintaining WearOS code long term? Just for my expectation of expected (future) workload :) |
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
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
|
@TheLastProject thank you for your reply! I had the same uncertainty around the non-free WearOS dependencies. I eventually resorted to implementing my own Bluetooth service to let the phone and watch communicate (also because GMS didn't work on my sandboxed Google Play Services on GrapheneOS), but there's still a GMS fallback that makes the app dependent on Google's non-free libraries, I agree. I'm not very familiar with F-Droid's requirements when it comes to WearOS apps and I don't know if there are some relevant examples I could take a look at. But my guess is that without the GMS dependency and with a custom Bluetooth listener to replace it the lights should be green, right? If that's still not the case I'll be happy to split the WearOS implementation and make it only available for the Play Store build.
Of course 🙂 |
- 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)
Yeah, the issue is specifically in app/build.gradle.kts. That has to be excluded from the foss flavour. I think that can be solved by just saying With regards to testing, I see you removed the
That's great :) I see you're still pushing to this occasionally. Would you like me to start reviewing already or give you some times to finish up some stuff first? :) |
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.
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
|
I just merged the Katastima APK scanner into main. That should make it clear which dependencies are in which flavour if you rebase your MR on main :) |
- Added again to the `app` build.gradle.kts - Added also to the `wear` build
|
I just pushed two last changes:
The code should be ready for review now. One last open question would be about the packaging/release process. I don't know of other WearOS apps distributed on F-Droid so I'm not sure how the distribution process for companion apps would look like. On the Play Store the APKs are usually bundled, not sure about F-Droid - if they can be released together or the WearOS app needs another store entry. Also, I guess that a mention in the README/docs is also needed? |
Great, I'll look soon. Though the lint seems to be failing. We should also see if we can do the whole build/test of the WearOS app in GitHub Actions. But that's not something to delay review (what delays review is me not having time yet but I'll make time soon!)
I don't believe it is possible to write WearOS apps without proprietary dependencies, so that's a no-go for F-Droid. However, I can post it to GitHub and maybe it can be published to IzzyOnDroid. I don't really know enough yet about installing apps on WearOS, trying to hunt down a cheap second-hand WearOS watch and then discuss with Izzy afterwards :)
I guess so, but no rush, we can also set the documentation up after this is merged :) |
|
Not completely sure but I think this may be what we want as a new I removed all the test stuff, as the wear app seems to lack tests (which is understandable, I'm not going to complain). |
TheLastProject
left a comment
There was a problem hiding this comment.
This looks quite reasonable! I feel I don't have much complaints about the general structure, great.
I haven't been able to test it yet, but I'm in talks with someone to get a second hand WearOS device. I may run into some more issues when testing on device, but for now, this is my feedback (also don't forget about the suggested GitHub Actions workflow please :))
Fixes failing Github Actions LINT job.
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: CatimaLoyalty#3185 (comment)
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: CatimaLoyalty#3185 (comment)
|
I expect to have a WearOS smartwatch by tuesday evening so I may be able to test this on-device Tuesday evening or Wednesday, assuming the official WearOS app actually works properly. So I can return to this with more on-device experience then. |
…ous protocol. Addresses: CatimaLoyalty#3185 (comment)
…ings - 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: CatimaLoyalty#3185 (comment)
…Screen - 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: CatimaLoyalty#3185 (comment)
TheLastProject
left a comment
There was a problem hiding this comment.
I think this may almost be ready to be merged at least into a wearos branch. I'm a bit hesitant to release this as-is because I'm not sure if the assumption that any paired Bluetooth device can be trusted is one that holds true.
However, I think that it may be good to do some security hardening in a separate MR after this base v1 MR is merged, to prevent scope creeping too much.
I think this is basically ready aside from a small refactor.
I do seem to have performance issues, but I do think they may just be issues with the watch itself? I'm not seeing any obvious big mistakes. And it's usable enough, so whatever :)
Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
Use distinct `applicationId` for wear Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
- Add /VERSIONS endpoint and versioned /V1/ command prefix - Drop version field from page responses and validate via negotiation on watch - Stop repeated fetch attempts when protocol is incompatible Resolves: CatimaLoyalty#3185 (comment)
Always start the Wear OS Bluetooth server from the Application when Wear sync is on; the service already stops itself if BLUETOOTH_CONNECT is missing. Request the permission again from MainActivity.onResume so that Android's auto-revocation of unused app permissions doesn't leave Wear sync silently broken.
Fixed ending newline Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
Add SYNC_ERROR fetch status for unparseable phone responses Resolves: CatimaLoyalty#3185 (comment)
Add SYNC_ERROR fetch status for unparseable phone responses Resolves: CatimaLoyalty#3185 (comment)
TheLastProject
left a comment
There was a problem hiding this comment.
FYI, you have a merge conflict on gradle/libs.versions.toml now, probably due to the new release, so that needs fixing.
Also still have some nitpicks, sorry 😅
| } | ||
|
|
||
| buildTypes { | ||
| release { |
There was a problem hiding this comment.
I agree it's a good improvement, especially because Google notes the old system is deprecated. I made a new issue for that, we can do that for app + wear after this I think: #3208
|
|
||
| android { | ||
| namespace = "me.hackerchick.catima.wear" | ||
| compileSdk = 36 |
There was a problem hiding this comment.
I agree it would be an improvement as soon as we can use targetSdk 37.
I configured it in https://github.com/CatimaLoyalty/Android/pull/3161/changes, will also bump the wear app after it's been merged. Let me know in that PR if you think I'm making a mistake. For now, the tests fails because Robolectric doesn't support SDK 37 yet.
|
|
||
| setContent { | ||
| CatimaWearTheme { | ||
| val navController = rememberSwipeDismissableNavController() |
There was a problem hiding this comment.
I trust @theimpulson's judgment in that we should use navigation3. However, I'm okay with leaving that for a future PR as well so we can move forward with getting this merged and getting something usable into the hands of users :)
|
Sorry if this review is a bit chaotic, some of your comments came in while I was responding and I missed them. I think I looked at everything and replied to everything but if I missed something please let me know |
* main: (24 commits) Bump com.android.application from 9.2.1 to 9.3.0 Bump Fastlane version Release Catima 2.43.0 Update Fastlane changelogs Update CHANGELOG Add internal image viewer for zooming (CatimaLoyalty#3184) Bump androidx.test.uiautomator:uiautomator from 2.3.0 to 2.4.0 Bump androidx.compose:compose-bom from 2026.06.00 to 2026.06.01 Bump ReactiveCircus/android-emulator-runner from 2 to 2.37.0 Update CONTRIBUTING.md docs: highlight llm usage note docs: note on migration for contributors Bump gradle-wrapper from 9.6.0 to 9.6.1 Update Fastlane changelogs Update CHANGELOG Merge pull request CatimaLoyalty#3191 from Bhargav-24/add-most-recently-added-sort Update Fastlane changelogs Update CHANGELOG Merge pull request CatimaLoyalty#3174 from mvanhorn/feat/2742-display-card-id Translated using Weblate (Persian) ...
Extract Wear OS sync permission handling and Bluetooth server service start/stop logic into a shared manager. Update the main and settings activities to use it, ensuring the service is stopped when sync is disabled or permission is denied/revoked.
Replace the local `BluetoothCardClient.FetchStatus` enum with the shared `SyncStatus` enum so the wear module uses one status type across `BluetoothCardClient`, `MainActivity`, and `WearCardRepository`. Addresses: CatimaLoyalty#3185 (comment)
| wearSyncPreference!!.setOnPreferenceChangeListener { _, newValue -> | ||
| wearSyncPermissionRequester.onWearSyncChanged(newValue as Boolean) | ||
| true | ||
| } |
There was a problem hiding this comment.
When activating "Sync with Wear OS", when the user denies the permission request, the toggle will still be activated. It shouldn't be.
There was a problem hiding this comment.
Even worse, that seems to have broken the main screen and settings screen. I couldn't use anything anymore and when closing and reopening Catima the home screen was so sluggish it became basically unusable.
My logs are being spammed so much I can't see what's happening. Is it maybe stuck in a loop in permission handling?
There was a problem hiding this comment.
There is a state bug with permission request in settings.kt I see remove the extra state or add a expection for it to prevent crashes in the future
There was a problem hiding this comment.
That's a good catch. I also noticed slugginess but I didn't connect it to the repeated BT permissions check.
I've tried to make the logic a bit smarter here: 6fa800d
There was a problem hiding this comment.
That seems to work fine. It recovered and it doesn't crash even when declining the permission too often.
I'd say this is pretty much perfect now (I quite like how the Wear app will try to sync on resume, meaning that if you start the app or wake it up for sleep you see your cards while it syncs on the background), I just have one final nitpick on permission handling: if you deny the permission twice, the toggle will stay disabled with no feedback when you try to enable it again (because of Android blocking constant re-requesting). Can we maybe show a toast, just when tapping it on the settings activity when the permission is denied stating something like "Allow Catima access to nearby devices to sync with your smartwatch"? Or maybe a Snackbar would be even better, then you could open the permissions screen straight from that snackbar:
Android/app/src/main/java/protect/card_locker/ScanActivity.kt
Lines 530 to 537 in dbd0727
There was a problem hiding this comment.
There you go with your snackbar on denied sync permissions :) c64c3b8
… denied Prevent the Wear OS sync switch from staying enabled after the user denies the Bluetooth permission, which caused repeated permission requests and UI sluggishness. The setting is now only persisted as enabled once permission is granted, and the service manager disables it on denial to break the request loop. Addresses: CatimaLoyalty#3185 (comment)
|
Thanks a lot for this. I do have some things I still want to deal with before going live with this (see #25 (comment)), but this part is approved at least, so I merged it into a new temporary Once we have these last fixes/features merged into the branch I think we can merge it into main and make a new release :) |
|
Oh, I also forgot to test below Android 12, will still have to do so |

Add WearOS companion app
Adds a standalone Wear OS app that displays a user's loyalty cards and renders their barcodes directly on the watch.
Addresses:
Closes:
What's new
Wear OS module (
wear/)BluetoothCardClient— GMS-free RFCOMM client that connects to any bonded device advertising the Catima service UUID and fetches card JSON over a newline-delimited protocolWearDataListenerService— GMSWearableListenerServiceretained as automatic fallback for standard Android + Google Play Services setupsPhone module (
app/)BluetoothServerService— RFCOMM server that starts on app launch, listens forCARDS_REQUESTcommands from the watch and responds with card list JSONWearDataService— GMSWearableListenerServiceretained as fallbackBLUETOOTH_CONNECTruntime permission requested on first launch (Android 12+)Transport strategy
The watch tries Bluetooth Classic (RFCOMM) first. This works without Google Play Services and is therefore compatible with GrapheneOS and other setups where sandboxed GMS cannot route Wearable Data Layer messages. If no bonded device responds on the Catima UUID, it falls back to the GMS
MessageClientpath, which works on standard Android.Key fixes
org.json.JSONObject.optString()returns the literal string"null"for JSON null values; replaced withisNull()guards inWearCard.fromJsonto prevent ZXing receiving"null"as a barcode valueBoxWithConstraintsso they do not overflow the circular display boundaryPermissions added
BLUETOOTH/BLUETOOTH_ADMIN(≤ API 30, no prompt required)BLUETOOTH_CONNECT(API 31+, runtime prompt on both phone and watch)Testing
Verified on Pixel Watch 3 (Wear OS 5) paired with Pixel 9a running GrapheneOS with the F-Droid build of Catima. Full round-trip latency phone → watch is approximately 400 ms over Bluetooth Classic.
Screenshots