Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bf658e4
feat: add WearOS app with GMS-based Wearable Data Layer transport
blacklight Jul 2, 2026
5a6aca2
feat: add GMS-free Bluetooth RFCOMM transport for Wear OS
blacklight Jul 2, 2026
9f6d0d4
fix: resolve two wear card view bugs
blacklight Jul 2, 2026
302af3d
fix: re-fetch cards on onResume to recover from post-reboot spinner
blacklight Jul 2, 2026
60df23c
fix: prevent BluetoothServerService from being killed by app idle
blacklight Jul 2, 2026
a1d00e9
feat: keep screen on at full brightness while barcode is displayed
blacklight Jul 2, 2026
744a745
refactor: remove all GMS/play-services-wearable dependencies
blacklight Jul 2, 2026
aa72cc1
feat: persist cards locally on watch for offline access
blacklight Jul 2, 2026
7f08786
chore(build): Set debug applicationIdSuffix
blacklight Jul 2, 2026
b7cbe83
fix: check BLUETOOTH_CONNECT permission at call site in AcceptThread
blacklight Jul 4, 2026
06c2fc5
refactor: Centralise notification/channel IDs in NotificationInfo
blacklight Jul 4, 2026
71bbb86
chore: Addressed LINT warning on rejected Bluetooth permission
blacklight Jul 4, 2026
75fd1f2
chore: Addressed LINT warnings in BluetoothServerService
blacklight Jul 4, 2026
33a36ef
feat(wear): Version the Bluetooth sync protocol
blacklight Jul 4, 2026
6b769c6
refactor(bluetooth): Move BluetoothServerService to wearos package
blacklight Jul 4, 2026
5778d3e
feat(app): Add Wear OS sync setting and gate Bluetooth server
blacklight Jul 4, 2026
f4a45bc
refactor(bt-service): Remove pointless compatibility layer with previ…
blacklight Jul 6, 2026
4fca884
feat(wear): Apply the same sorting as the phone app.
blacklight Jul 6, 2026
c3511d8
feat(wear): Default Wear OS sync to off; move permission flow to Sett…
blacklight Jul 6, 2026
08f352b
fix(wear): Use LocalActivity instead of LocalContext cast in CardView…
blacklight Jul 6, 2026
c53e05d
feat(wear): Surface distinct Bluetooth error states to the user
blacklight Jul 6, 2026
0a4374a
fix(wear): surface card parse failures to the user instead of silentl…
blacklight Jul 6, 2026
eb155f9
chore(wear): Replaced deprecated SharedPreferences usage
blacklight Jul 6, 2026
fc1c43d
chore(gitignore): Ignore /wear/release
blacklight Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
/app/*.log
/app/build
/app/release
/wear/build
Comment thread
blacklight marked this conversation as resolved.
/wear/release
/.idea/*
!/.idea/icon.svg
# Bundle
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23" />

<uses-feature
Expand Down Expand Up @@ -223,5 +228,10 @@
<action android:name="android.service.controls.ControlsProviderService" />
</intent-filter>
</service>

<service
android:name=".wearos.BluetoothServerService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package protect.card_locker;

import android.app.Application;
import android.content.Intent;

import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;

import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
Expand All @@ -11,6 +13,7 @@
import org.acra.data.StringFormat;

import protect.card_locker.preferences.Settings;
import protect.card_locker.wearos.BluetoothServerService;

public class LoyaltyCardLockerApplication extends Application {

Expand Down Expand Up @@ -41,5 +44,13 @@ public void onCreate() {
// Set theme
Settings settings = new Settings(this);
AppCompatDelegate.setDefaultNightMode(settings.getTheme());

// 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));
}
}
}
15 changes: 3 additions & 12 deletions app/src/main/java/protect/card_locker/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -467,11 +467,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
ShortcutHelper.updateShortcuts(mAdapter.mContext)
}

private fun processParseResultList(
parseResultList: MutableList<ParseResult?>,
group: String?,
closeAppOnNoBarcode: Boolean
) {
private fun processParseResultList(parseResultList: MutableList<ParseResult?>) {
require(!parseResultList.isEmpty()) { "parseResultList may not be empty" }

Utils.makeUserChooseParseResultFromList(
Expand All @@ -482,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()
}
})
}
Expand Down Expand Up @@ -556,7 +547,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
return
}

processParseResultList(parseResultList, null, true)
processParseResultList(parseResultList)
}

private fun extractIntentFields(intent: Intent) {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/protect/card_locker/NotificationInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package protect.card_locker

object NotificationInfo {
object WearBluetooth {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "catima_wear_bt"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, false);
}
}
Original file line number Diff line number Diff line change
@@ -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() {

Expand Down Expand Up @@ -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)
Expand All @@ -104,7 +127,7 @@ class SettingsActivity : CatimaAppCompatActivity() {

val oledDarkPreference = findPreference<Preference>(getString(R.string.settings_key_oled_dark))
oledDarkPreference!!.setOnPreferenceChangeListener { _, _ ->
refreshActivity(true)
refreshActivity()
true
}

Expand Down Expand Up @@ -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
Expand All @@ -157,13 +180,32 @@ class SettingsActivity : CatimaAppCompatActivity() {
true
}

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
}

Comment on lines +183 to +201

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@TheLastProject TheLastProject Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

// Hide crash reporter settings on builds it's not enabled on
val crashReporterPreference = findPreference<Preference>("acra.enable")
crashReporterPreference!!.isVisible = BuildConfig.useAcraCrashReporter
}

private fun refreshActivity(reloadMain: Boolean) {
mReloadMain = reloadMain || mReloadMain
private fun refreshActivity() {
mReloadMain = true
activity?.recreate()
}
}
Expand Down
Loading