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)
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: CatimaLoyalty#3185 (comment)
…y 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: CatimaLoyalty#3185 (comment)
|
@TheLastProject I have tried to address all the comments. Let me know if you have any luck testing this on a real WearOS device 🙂 |
|
I'll review soon, don't know if I'll still have time tonight. Can you add the GitHub Actions file suggested in #3185 (comment)? Then we can also build and lint the WearOS app directly in the MR, which may show some new stuff :) |
TheLastProject
left a comment
There was a problem hiding this comment.
I'm sadly not able to get the watch and the phone to connect even though the devices are connected over Bluetooth. Could you double check this?
| <resources> | ||
| <string name="app_name">Catima</string> | ||
| <string name="no_cards">No cards found.\nOpen Catima on your phone to add cards.</string> | ||
| <string name="phone_not_connected">Could not reach Catima on your phone.\nMake sure the app is installed and your watch is connected.</string> |
There was a problem hiding this comment.
| <string name="phone_not_connected">Could not reach Catima on your phone.\nMake sure the app is installed and your watch is connected.</string> | |
| <string name="phone_not_connected">Could not reach Catima on your phone.\nMake sure the app is installed, your watch is connected and WearOS is enabled in Catima settings.</string> |
| val wearSyncPreference = findPreference<SwitchPreferenceCompat>(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 | ||
| } | ||
|
|
There was a problem hiding this comment.
On my test devices (Samsung Galaxy S20+ 5G (Android 13), Samsung Galaxy Watch 4) toggling this on does not seem to work. I've tried to restart the watch and the phone after enabling to no avail. I keep getting the "Could not reach Catima on your phone" alert. Anything I'm missing?
I also noticed it didn't ask for bluetooth/location permission. Could it be you broke the permission request code in refactoring?
There was a problem hiding this comment.
Sorry, I was wrong about the permission request issue. I seemed to have had that permission granted already and forgot 😅 Sadly it not syncing is staying an issue.
There was a problem hiding this comment.
Just noticed this in the logging.
Watch side:
2026-07-07 20:50:03.420 10266-10293 BluetoothSocket me.hackerchick.catima.wear.debug I connect(), socket connected. mPort=10
2026-07-07 20:50:03.452 1115-5468 BluetoothAdapterService com.android.bluetooth E setSocketUsed() [10] me.hackerchick.catima.wear.debug (RFCOMM) / true
2026-07-07 20:50:03.484 10266-10293 CatimaBtClient me.hackerchick.catima.wear.debug D Connected to Galaxy S20+ 5G
2026-07-07 20:50:03.956 10266-10293 CatimaBtClient me.hackerchick.catima.wear.debug D Failed to connect to Galaxy S20+ 5G: bt socket closed, read return: -1
2026-07-07 20:50:03.957 10266-10293 BluetoothSocket me.hackerchick.catima.wear.debug D close() this: XX:XX:XX:XX:C9:AD, channel: 10, mSocketIS: android.net.LocalSocketImpl$SocketInputStream@44509f9, mSocketOS: android.net.LocalSocketImpl$SocketOutputStream@dbef79f, mSocket: android.net.LocalSocket@8c34cb5 impl:android.net.LocalSocketImpl@584ff4a fd:java.io.FileDescriptor@442069d, mSocketState: CONNECTED
2026-07-07 20:50:03.978 1115-5468 BluetoothAdapterService com.android.bluetooth E setSocketUsed() [10] me.hackerchick.catima.wear.debug (RFCOMM) / false
2026-07-07 20:50:03.979 10266-10293 CatimaBtClient me.hackerchick.catima.wear.debug W No Catima phone found among bonded devices
2026-07-07 20:50:03.980 10266-10293 CatimaWear me.hackerchick.catima.wear.debug W Bluetooth failed, phone not reachable
2026-07-07 20:50:03.660 20648-20730 BluetoothSocket me.hackerchick.catima.debug I acceptSocket() for device D01B49_F
2026-07-07 20:50:03.663 20648-20730 BluetoothSocket me.hackerchick.catima.debug D return acceptedSocket=android.bluetooth.BluetoothSocket@1aa187c
2026-07-07 20:50:03.666 20648-20730 CatimaBtServer me.hackerchick.catima.debug D Connected to Galaxy Watch4 (45SX)
2026-07-07 20:50:03.667 20648-20730 BluetoothSocket me.hackerchick.catima.debug D read in: android.net.LocalSocketImpl$SocketInputStream@4ca6505 len: 8192
2026-07-07 20:50:03.764 20648-20730 BluetoothSocket me.hackerchick.catima.debug D read out: android.net.LocalSocketImpl$SocketInputStream@4ca6505 ret: 17
2026-07-07 20:50:03.765 20648-20730 CatimaBtServer me.hackerchick.catima.debug D Received command: V1/CARDS_REQUEST from Galaxy Watch4 (45SX)
2026-07-07 20:50:03.791 20648-20730 BluetoothSocket me.hackerchick.catima.debug D write: android.net.LocalSocketImpl$SocketOutputStream@fbe4068 length: 8192
2026-07-07 20:50:03.791 20648-20730 BluetoothSocket me.hackerchick.catima.debug D write out: android.net.LocalSocketImpl$SocketOutputStream@fbe4068 length: 8192
2026-07-07 20:50:03.792 20648-20730 BluetoothSocket me.hackerchick.catima.debug D write: android.net.LocalSocketImpl$SocketOutputStream@fbe4068 length: 8192
2026-07-07 20:50:03.792 20648-20730 BluetoothSocket me.hackerchick.catima.debug D write out: android.net.LocalSocketImpl$SocketOutputStream@fbe4068 length: 8192
2026-07-07 20:50:03.792 20648-20730 BluetoothSocket me.hackerchick.catima.debug D write: android.net.LocalSocketImpl$SocketOutputStream@fbe4068 length: 7326
2026-07-07 20:50:03.792 20648-20730 BluetoothSocket me.hackerchick.catima.debug D write out: android.net.LocalSocketImpl$SocketOutputStream@fbe4068 length: 7326
2026-07-07 20:50:03.792 20648-20730 CatimaBtServer me.hackerchick.catima.debug D Sent 23709 bytes to Galaxy Watch4 (45SX)
2026-07-07 20:50:03.792 20648-20730 BluetoothSocket me.hackerchick.catima.debug D close() this: android.bluetooth.BluetoothSocket@1aa187c, channel: 10, mSocketIS: android.net.LocalSocketImpl$SocketInputStream@4ca6505, mSocketOS: android.net.LocalSocketImpl$SocketOutputStream@fbe4068, mSocket: android.net.LocalSocket@4114c81 impl:android.net.LocalSocketImpl@d731126 fd:java.io.FileDescriptor@29c1167, mSocketState: CONNECTED
2026-07-07 20:50:03.794 20648-20730 BluetoothServerSocket me.hackerchick.catima.debug W BluetoothServerSocket: accept() called. mSocket=android.bluetooth.BluetoothSocket@e1460af,timeout=-1
So it looks like it does connect, retrieve the card request and data gets sent, but somehow the watch thinks the connection got closed unexpectedly?
There was a problem hiding this comment.
I have a lot of cards (194 to be exact) on this test device because I was also testing some lag issues. Maybe that's the issue here?
There was a problem hiding this comment.
I think we can simplify the return value. If we just have the watch call a /VERSIONS endpoint and the Android app returns supported versions (for now only [1] or something like that), the watch app checks if its own version is in that list and if it's not displays an error to tell you to update the watch app and Android app and otherwise just calls the relevant, in this case, V1/CARDS_REQUEST endpoint.
By doing versioning like that, we can also simplify buildCardsJson to not need to return a version and thus maybe more easily paginate and keep it simple?
I think paginating is a requirement sadly, though it shouldn't be too hard.
| @@ -0,0 +1,16 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
| <resources> | |||
| <string name="app_name">Catima</string> | |||
There was a problem hiding this comment.
Let's add a debug strings.xml too, like main Catima has, so I can tell regular and debug Catima WearOS apart :)

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