Add WearOS companion app (#3185)

Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
This commit is contained in:
Fabio Manganiello
2026-07-19 10:58:02 +01:00
committed by GitHub
parent 92776c9e8d
commit 3e970bae92
37 changed files with 1466 additions and 18 deletions

46
.github/workflows/wear.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Android Wear CI
on:
workflow_dispatch:
push:
branches:
- main
- staging
- trying
pull_request:
branches:
- main
permissions:
actions: none
checks: none
contents: read
deployments: none
discussions: none
id-token: none
issues: none
packages: none
pages: none
pull-requests: none
repository-projects: none
security-events: none
statuses: none
env:
JAVA_HOME: /usr/lib/jvm/java-25-openjdk-amd64
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.0
- uses: gradle/actions/wrapper-validation@v6.2.0
- name: set up OpenJDK 25
run: |
sudo apt-get update
sudo apt-get install -y openjdk-25-jdk-headless
sudo update-alternatives --auto java
- name: Build
run: ./gradlew :wear:assembleRelease
- name: Check for non-free libraries
run: |
wget -q https://codeberg.org/Katastima/apkscanner/releases/download/v0.0.7/apk-scanner_v0.0.7.jar
java -jar apk-scanner_v0.0.7.jar scan-apk 'wear/build/outputs/apk/release/wear-release-unsigned.apk'
- name: Check lint
run: ./gradlew :wear:lintRelease

3
.gitignore vendored
View File

@@ -19,6 +19,8 @@
/app/*.log
/app/build
/app/release
/wear/build
/wear/release
/.idea/*
!/.idea/icon.svg
# Bundle
@@ -28,3 +30,4 @@
# Catima-specific
SHA256SUMS
/shared/build

View File

@@ -113,6 +113,8 @@ android {
}
dependencies {
implementation(project(":shared"))
// AndroidX
implementation(libs.androidx.appcompat.appcompat)
implementation(libs.androidx.constraintlayout.constraintlayout)

View File

@@ -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
@@ -226,5 +231,10 @@
<action android:name="android.service.controls.ControlsProviderService" />
</intent-filter>
</service>
<service
android:name=".wearos.BluetoothServerService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>

View File

@@ -1,9 +1,9 @@
package protect.card_locker;
import android.app.Application;
import android.content.Intent;
import androidx.appcompat.app.AppCompatDelegate;
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
import org.acra.config.DialogConfigurationBuilder;
@@ -11,6 +11,7 @@ import org.acra.config.MailSenderConfigurationBuilder;
import org.acra.data.StringFormat;
import protect.card_locker.preferences.Settings;
import protect.card_locker.wearos.WearSyncServiceManager;
public class LoyaltyCardLockerApplication extends Application {
@@ -41,5 +42,10 @@ public class LoyaltyCardLockerApplication extends Application {
// Set theme
Settings settings = new Settings(this);
AppCompatDelegate.setDefaultNightMode(settings.getTheme());
// Start Bluetooth server for Wear OS companion if enabled.
// The service checks BLUETOOTH_CONNECT itself and stops if the permission is missing.
// The permission is requested from the launcher Activity when the UI resumes.
WearSyncServiceManager.INSTANCE.synchronize(this, null);
}
}

View File

@@ -37,6 +37,7 @@ import protect.card_locker.databinding.MainActivityBinding
import protect.card_locker.databinding.SortingOptionBinding
import protect.card_locker.preferences.Settings
import protect.card_locker.preferences.SettingsActivity
import protect.card_locker.wearos.WearSyncPermissionRequester
import java.io.UnsupportedEncodingException
import java.util.concurrent.atomic.AtomicInteger
import androidx.core.content.edit
@@ -61,6 +62,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
private lateinit var mUpdateLoyaltyCardListRunnable: Runnable
private lateinit var mBarcodeScannerLauncher: ActivityResultLauncher<Intent>
private lateinit var mSettingsLauncher: ActivityResultLauncher<Intent>
private lateinit var mWearSyncPermissionRequester: WearSyncPermissionRequester
private val mCurrentActionModeCallback: ActionMode.Callback = object : ActionMode.Callback {
override fun onCreateActionMode(inputMode: ActionMode, inputMenu: Menu?): Boolean {
@@ -318,6 +320,8 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
}
}
mWearSyncPermissionRequester = WearSyncPermissionRequester(this, this)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (mSearchView != null && !mSearchView!!.isIconified) {
@@ -394,6 +398,8 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
val settings = Settings(this)
layoutManager.setSpanCount(settings.getPreferredColumnCount())
}
mWearSyncPermissionRequester.synchronize()
}
private fun displayCardSetupOptions(menu: Menu, shouldShow: Boolean) {
@@ -467,11 +473,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
ShortcutHelper.updateShortcuts(mAdapter.mContext)
}
private fun processParseResultList(
parseResultList: MutableList<ParseResult?>,
group: String?,
closeAppOnNoBarcode: Boolean
) {
private fun processParseResultList(parseResultList: MutableList<ParseResult?>) {
require(!parseResultList.isEmpty()) { "parseResultList may not be empty" }
Utils.makeUserChooseParseResultFromList(
@@ -482,17 +484,12 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
val intent =
Intent(applicationContext, LoyaltyCardEditActivity::class.java)
val bundle = parseResult.toLoyaltyCardBundle(this@MainActivity)
if (group != null) {
bundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, group)
}
intent.putExtras(bundle)
startActivity(intent)
}
override fun onUserDismissedSelector() {
if (closeAppOnNoBarcode) {
finish()
}
finish()
}
})
}
@@ -556,7 +553,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener {
return
}
processParseResultList(parseResultList, null, true)
processParseResultList(parseResultList)
}
private fun extractIntentFields(intent: Intent) {

View File

@@ -0,0 +1,8 @@
package protect.card_locker
object NotificationInfo {
object WearBluetooth {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "catima_wear_bt"
}
}

View File

@@ -112,4 +112,8 @@ public class Settings {
public boolean useVolumeKeysForNavigation() {
return getBoolean(R.string.settings_key_use_volume_keys_navigation, false);
}
public boolean getWearSyncEnabled() {
return getBoolean(R.string.settings_key_wear_sync, false);
}
}

View File

@@ -1,9 +1,10 @@
package protect.card_locker.preferences
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatDelegate
@@ -11,12 +12,15 @@ import androidx.core.os.LocaleListCompat
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import com.google.android.material.snackbar.Snackbar
import protect.card_locker.BuildConfig
import protect.card_locker.CatimaAppCompatActivity
import protect.card_locker.MainActivity
import protect.card_locker.R
import protect.card_locker.Utils
import protect.card_locker.databinding.SettingsActivityBinding
import protect.card_locker.wearos.WearSyncPermissionRequester
class SettingsActivity : CatimaAppCompatActivity() {
@@ -81,7 +85,16 @@ class SettingsActivity : CatimaAppCompatActivity() {
class SettingsFragment : PreferenceFragmentCompat() {
var mReloadMain: Boolean = false
private lateinit var wearSyncPermissionRequester: WearSyncPermissionRequester
override fun onResume() {
super.onResume()
wearSyncPermissionRequester.synchronize()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
wearSyncPermissionRequester = WearSyncPermissionRequester(this, requireContext())
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.preferences)
@@ -104,7 +117,7 @@ class SettingsActivity : CatimaAppCompatActivity() {
val oledDarkPreference = findPreference<Preference>(getString(R.string.settings_key_oled_dark))
oledDarkPreference!!.setOnPreferenceChangeListener { _, _ ->
refreshActivity(true)
refreshActivity()
true
}
@@ -148,7 +161,7 @@ class SettingsActivity : CatimaAppCompatActivity() {
localePreference.setOnPreferenceChangeListener { _, newValue ->
// See corresponding comment in Utils.updateBaseContextLocale for Android 6- notes
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
refreshActivity(true)
refreshActivity()
return@setOnPreferenceChangeListener true
}
val newLocale = newValue as String
@@ -157,13 +170,46 @@ class SettingsActivity : CatimaAppCompatActivity() {
true
}
val wearSyncPreference = findPreference<SwitchPreferenceCompat>(getString(R.string.settings_key_wear_sync))
wearSyncPreference!!.setOnPreferenceChangeListener { preference, newValue ->
val enabled = newValue as Boolean
if (enabled) {
wearSyncPermissionRequester.onWearSyncChanged(true) { granted ->
if (granted) {
(preference as? SwitchPreferenceCompat)?.isChecked = true
} else {
showWearSyncPermissionDeniedSnackbar()
}
}
false
} else {
wearSyncPermissionRequester.onWearSyncChanged(false)
true
}
}
// Hide crash reporter settings on builds it's not enabled on
val crashReporterPreference = findPreference<Preference>("acra.enable")
crashReporterPreference!!.isVisible = BuildConfig.useAcraCrashReporter
}
private fun refreshActivity(reloadMain: Boolean) {
mReloadMain = reloadMain || mReloadMain
private fun showWearSyncPermissionDeniedSnackbar() {
Snackbar.make(
requireView(),
R.string.wear_sync_permission_required,
Snackbar.LENGTH_LONG
).setAction(R.string.open_settings) {
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", requireContext().packageName, null)
)
)
}.show()
}
private fun refreshActivity() {
mReloadMain = true
activity?.recreate()
}
}

View File

@@ -0,0 +1,215 @@
package protect.card_locker.wearos
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import org.json.JSONArray
import org.json.JSONObject
import protect.card_locker.DBHelper
import protect.card_locker.NotificationInfo
import protect.card_locker.R
import protect.card_locker.Utils
import protect.card_locker.shared.BluetoothPermissionHelper
import protect.card_locker.shared.WearBluetoothProtocol
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
class BluetoothServerService : Service() {
companion object {
private const val TAG = "CatimaBtServer"
private const val NOTIFICATION_ID = NotificationInfo.WearBluetooth.NOTIFICATION_ID
private const val CHANNEL_ID = NotificationInfo.WearBluetooth.CHANNEL_ID
}
private var serverThread: AcceptThread? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!BluetoothPermissionHelper.isBluetoothConnectGranted(this)) {
Log.w(TAG, "BLUETOOTH_CONNECT permission not granted, stopping")
stopSelf()
return START_NOT_STICKY
}
val adapter = (getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
if (adapter == null || !adapter.isEnabled) {
Log.w(TAG, "Bluetooth not available or disabled")
stopSelf()
return START_NOT_STICKY
}
startForegroundWithNotification()
serverThread?.cancel()
serverThread = AcceptThread(adapter).also { it.start() }
Log.d(TAG, "Bluetooth server started")
return START_STICKY
}
private fun startForegroundWithNotification() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.wear_bt_channel_name),
NotificationManager.IMPORTANCE_MIN
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.wear_bt_notification_title))
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setSilent(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
override fun onDestroy() {
serverThread?.cancel()
serverThread = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private inner class AcceptThread(adapter: BluetoothAdapter) : Thread() {
private var serverSocket: BluetoothServerSocket? = null
@Volatile private var running = true
init {
try {
if (BluetoothPermissionHelper.isBluetoothConnectGranted(this@BluetoothServerService)) {
serverSocket = adapter.listenUsingRfcommWithServiceRecord(
WearBluetoothProtocol.BT_SERVICE_NAME,
WearBluetoothProtocol.BT_SERVICE_UUID
)
} else {
Log.w(TAG, "BLUETOOTH_CONNECT permission missing, cannot open server socket")
running = false
}
} catch (e: SecurityException) {
Log.e(TAG, "BLUETOOTH_CONNECT permission missing, cannot create server socket", e)
running = false
} catch (e: Exception) {
Log.e(TAG, "Failed to create server socket", e)
running = false
}
}
override fun run() {
Log.d(TAG, "Listening for Bluetooth connections")
while (running) {
val socket: BluetoothSocket = try {
serverSocket?.accept() ?: break
} catch (e: Exception) {
if (running) Log.e(TAG, "Accept failed", e)
break
}
handleConnection(socket)
}
Log.d(TAG, "Accept loop ended")
if (running) {
Log.w(TAG, "Accept loop exited unexpectedly, restarting service")
stopSelf()
}
}
private fun handleConnection(socket: BluetoothSocket) {
val deviceName = try { socket.remoteDevice.name } catch (_: SecurityException) { "unknown" }
Log.d(TAG, "Connected to $deviceName")
try {
val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
val command = reader.readLine()?.trim()
Log.d(TAG, "Received command: $command from $deviceName")
if (command == WearBluetoothProtocol.BT_CMD_VERSIONS) {
val versions = JSONArray().put(WearBluetoothProtocol.PROTOCOL_VERSION).toString()
writer.println(versions)
writer.flush()
Log.d(TAG, "Sent supported versions to $deviceName")
} else if (command != null && command.startsWith("/V1/")) {
when {
command.startsWith(WearBluetoothProtocol.BT_CMD_CARDS_PAGE_PREFIX) -> {
val pageIndex = command.removePrefix(WearBluetoothProtocol.BT_CMD_CARDS_PAGE_PREFIX).toIntOrNull()
if (pageIndex == null || pageIndex < 0) {
Log.w(TAG, "Invalid page index in command: $command from $deviceName")
} else {
val json = buildCardsPageJson(pageIndex)
writer.println(json)
writer.flush()
Log.d(TAG, "Sent page $pageIndex (${json.length} bytes) to $deviceName")
}
}
else -> Log.w(TAG, "Unsupported V1 command: $command from $deviceName")
}
} else {
Log.w(TAG, "Unsupported protocol version for command: $command from $deviceName")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling connection from $deviceName", e)
} finally {
try { socket.close() } catch (_: Exception) { }
}
}
private fun buildCardsPageJson(pageIndex: Int): String {
val dbHelper = DBHelper(this@BluetoothServerService)
val db = dbHelper.readableDatabase
val order = Utils.getLoyaltyCardOrder(this@BluetoothServerService)
val orderDirection = Utils.getLoyaltyCardOrderDirection(this@BluetoothServerService)
val cursor = DBHelper.getLoyaltyCardCursor(
db, "", null, order, orderDirection,
DBHelper.LoyaltyCardArchiveFilter.Unarchived
)
val totalCards = cursor.count
val totalPages = if (totalCards == 0) 1 else (totalCards + WearBluetoothProtocol.PAGE_SIZE - 1) / WearBluetoothProtocol.PAGE_SIZE
val offset = pageIndex * WearBluetoothProtocol.PAGE_SIZE
val array = JSONArray()
cursor.use {
if (it.move(offset + 1)) {
val idIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID)
val storeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE)
val cardIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.CARD_ID)
val barcodeIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_ID)
val barcodeTypeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE)
val headerColorIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.HEADER_COLOR)
var count = 0
do {
array.put(JSONObject().apply {
put("id", it.getInt(idIdx))
put("store", it.getString(storeIdx) ?: "")
put("cardId", it.getString(cardIdIdx) ?: "")
put("barcodeId", if (it.isNull(barcodeIdIdx)) JSONObject.NULL else it.getString(barcodeIdIdx))
put("barcodeType", if (it.isNull(barcodeTypeIdx)) JSONObject.NULL else it.getString(barcodeTypeIdx))
put("headerColor", if (it.isNull(headerColorIdx)) JSONObject.NULL else it.getInt(headerColorIdx))
})
count++
} while (count < WearBluetoothProtocol.PAGE_SIZE && it.moveToNext())
}
}
return JSONObject().apply {
put("page", pageIndex)
put("totalPages", totalPages)
put("cards", array)
}.toString()
}
fun cancel() {
running = false
try { serverSocket?.close() } catch (_: Exception) { }
}
}
}

View File

@@ -0,0 +1,98 @@
package protect.card_locker.wearos
import android.Manifest
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import protect.card_locker.R
import protect.card_locker.preferences.Settings
import protect.card_locker.shared.BluetoothPermissionHelper
class WearSyncPermissionRequester(
caller: ActivityResultCaller,
context: Context
) {
private val context = context.applicationContext
private var pendingResultCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher = caller.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
pendingResultCallback?.invoke(granted)
WearSyncServiceManager.onPermissionResult(this.context, granted)
pendingResultCallback = null
}
fun synchronize() {
WearSyncServiceManager.synchronize(context) {
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
fun onWearSyncChanged(enabled: Boolean, onPermissionResult: ((Boolean) -> Unit)? = null) {
val applied = WearSyncServiceManager.onWearSyncChanged(context, enabled) {
pendingResultCallback = onPermissionResult
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
if (applied) {
onPermissionResult?.invoke(true)
}
}
}
object WearSyncServiceManager {
fun synchronize(context: Context, requestPermission: (() -> Unit)? = null) {
if (!Settings(context).wearSyncEnabled) {
stop(context)
} else if (BluetoothPermissionHelper.isBluetoothConnectGranted(context)) {
start(context)
} else if (requestPermission != null) {
requestPermission()
} else {
stop(context)
}
}
fun onWearSyncChanged(
context: Context,
enabled: Boolean,
requestPermission: () -> Unit
): Boolean {
return if (enabled) {
if (BluetoothPermissionHelper.isBluetoothConnectGranted(context)) {
start(context)
true
} else {
requestPermission()
false
}
} else {
stop(context)
true
}
}
fun onPermissionResult(context: Context, granted: Boolean) {
if (granted && Settings(context).wearSyncEnabled) {
start(context)
} else {
stop(context)
if (!granted) {
PreferenceManager.getDefaultSharedPreferences(context).edit {
putBoolean(context.getString(R.string.settings_key_wear_sync), false)
}
}
}
}
private fun start(context: Context) {
context.startService(Intent(context, BluetoothServerService::class.java))
}
private fun stop(context: Context) {
context.stopService(Intent(context, BluetoothServerService::class.java))
}
}

View File

@@ -304,6 +304,12 @@
<string name="setting_key_column_count_landscape" translatable="false">pref_column_count_landscape</string>
<string name="settings_category_title_general">General</string>
<string name="settings_category_title_privacy">Privacy</string>
<string name="settings_category_title_wear">Wear OS</string>
<string name="settings_key_wear_sync" translatable="false">pref_wear_sync</string>
<string name="settings_wear_sync">Sync with Wear OS</string>
<string name="settings_wear_sync_summary">Allow a Wear OS companion app to read your cards over Bluetooth</string>
<string name="wear_sync_permission_required">Allow Catima access to nearby devices to sync with your smartwatch</string>
<string name="open_settings">Open settings</string>
<string name="action_display_options">Display options</string>
<string name="show_archived_cards">Show archived cards</string>
<string name="view_online">View online</string>
@@ -353,4 +359,6 @@
<string name="nfc_blocked_while_viewing_card">NFC is paused</string>
<string name="change_settings">Change settings</string>
<string name="nfc_block_system_error">Failed to pause NFC</string>
<string name="wear_bt_channel_name">Wear OS companion</string>
<string name="wear_bt_notification_title">Catima watch sync active</string>
</resources>

View File

@@ -134,4 +134,18 @@
app:singleLineTitle="false" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/settings_category_title_wear"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:widgetLayout="@layout/preference_material_switch"
android:defaultValue="false"
android:key="@string/settings_key_wear_sync"
android:summary="@string/settings_wear_sync_summary"
android:title="@string/settings_wear_sync"
app:iconSpaceReserved="false"
app:singleLineTitle="false" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,37 @@
package protect.card_locker.wearos
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import protect.card_locker.R
@RunWith(RobolectricTestRunner::class)
class WearSyncServiceManagerTest {
@Test
fun onPermissionResult_denied_disablesWearSync() {
val context = ApplicationProvider.getApplicationContext<Context>()
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit().putBoolean(context.getString(R.string.settings_key_wear_sync), true).commit()
WearSyncServiceManager.onPermissionResult(context, false)
assertFalse(prefs.getBoolean(context.getString(R.string.settings_key_wear_sync), true))
}
@Test
fun onPermissionResult_granted_keepsWearSyncEnabled() {
val context = ApplicationProvider.getApplicationContext<Context>()
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit().putBoolean(context.getString(R.string.settings_key_wear_sync), true).commit()
WearSyncServiceManager.onPermissionResult(context, true)
assertTrue(prefs.getBoolean(context.getString(R.string.settings_key_wear_sync), false))
}
}

View File

@@ -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 {

View File

@@ -9,6 +9,9 @@ acra = "5.13.1"
# Testing
androidXTest = "1.7.0"
# Wear OS
wearCompose = "1.6.2"
[libraries]
# AndroidX
androidx-appcompat-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" }
@@ -23,6 +26,7 @@ com-google-android-material-material = { group = "com.google.android.material",
com-android-tools-desugar_jdk_libs = { group = "com.android.tools", name = "desugar_jdk_libs", version = "2.1.5" }
# Compose
androidx-activity-activity = { group = "androidx.activity", name = "activity", version = "1.13.0" }
androidx-activity-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.13.0" }
androidx-compose-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose" }
androidx-compose-foundation-foundation = { group = "androidx.compose.foundation", name = "foundation" }
@@ -42,6 +46,11 @@ net-lingala-zip4j-zip4j = { group = "net.lingala.zip4j", name = "zip4j", version
ch-acra-acra-mail = { group = "ch.acra", name = "acra-mail", version.ref = "acra" }
ch-acra-acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = "acra" }
# Wear OS
androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
# Testing
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidXTest" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTest" }
@@ -54,6 +63,7 @@ org-robolectric-robolectric = { group = "org.robolectric", name = "robolectric",
[plugins]
com-android-application = { id = "com.android.application", version = "9.3.0" }
com-android-library = { id = "com.android.library", version = "9.2.1" }
org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
org-jetbrains-kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View File

@@ -21,3 +21,5 @@ dependencyResolutionManagement {
}
rootProject.name = "Catima"
include(":app")
include(":wear")
include(":shared")

29
shared/build.gradle.kts Normal file
View File

@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.com.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
}
android {
namespace = "protect.card_locker.shared"
compileSdk = 36
defaultConfig {
minSdk = 23
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
}
}
}
dependencies {
api(libs.androidx.activity.activity)
api(libs.androidx.core.core.ktx)
}

View File

@@ -0,0 +1,34 @@
package protect.card_locker.shared
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.content.ContextCompat
object BluetoothPermissionHelper {
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
fun isBluetoothConnectRequired(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
fun isBluetoothConnectGranted(context: Context): Boolean =
!isBluetoothConnectRequired() ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
fun requestBluetoothConnectIfNeeded(
context: Context,
launcher: ActivityResultLauncher<String>,
onGranted: () -> Unit
) {
if (isBluetoothConnectGranted(context)) {
onGranted()
} else {
launcher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
}

View File

@@ -0,0 +1,12 @@
package protect.card_locker.shared
import java.util.UUID
object WearBluetoothProtocol {
val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90")
const val BT_SERVICE_NAME = "CatimaWear"
const val PROTOCOL_VERSION = 1
const val BT_CMD_VERSIONS = "/VERSIONS"
const val BT_CMD_CARDS_PAGE_PREFIX = "/V${PROTOCOL_VERSION}/CARDS_REQUEST_PAGE/"
const val PAGE_SIZE = 10
}

55
wear/build.gradle.kts Normal file
View File

@@ -0,0 +1,55 @@
plugins {
alias(libs.plugins.com.android.application)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.org.jetbrains.kotlin.plugin.compose)
}
kotlin {
jvmToolchain(21)
}
android {
namespace = "me.hackerchick.catima.wear"
compileSdk = 36
defaultConfig {
applicationId = "me.hackerchick.catima"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
applicationIdSuffix = ".debug"
}
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":shared"))
implementation(libs.androidx.core.core.ktx)
implementation(libs.androidx.activity.activity.compose)
// Wear OS Compose
implementation(libs.androidx.wear.compose.material)
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.navigation)
// ZXing for barcode rendering
implementation(libs.com.google.zxing.core)
}

1
wear/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
-keep class me.hackerchick.catima.wear.** { *; }

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name">Catima Debug</string>
</resources>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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-feature android:name="android.hardware.type.watch" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,163 @@
package me.hackerchick.catima.wear
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import protect.card_locker.shared.BluetoothPermissionHelper
import protect.card_locker.shared.WearBluetoothProtocol
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
object BluetoothCardClient {
private const val TAG = "CatimaBtClient"
fun fetchCards(context: Context, onResult: (cards: String?, status: SyncStatus) -> Unit) {
if (!BluetoothPermissionHelper.isBluetoothConnectGranted(context)) {
Log.w(TAG, "BLUETOOTH_CONNECT permission not granted")
onResult(null, SyncStatus.PERMISSION_DENIED)
return
}
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
if (adapter == null || !adapter.isEnabled) {
Log.w(TAG, "Bluetooth not available or disabled")
onResult(null, SyncStatus.BLUETOOTH_DISABLED)
return
}
val bondedDevices = try {
adapter.bondedDevices
} catch (e: SecurityException) {
Log.w(TAG, "BLUETOOTH_CONNECT permission rejected", e)
emptySet<android.bluetooth.BluetoothDevice>()
}
if (bondedDevices.isEmpty()) {
Log.w(TAG, "No bonded devices found")
onResult(null, SyncStatus.PHONE_NOT_REACHABLE)
return
}
Log.d(TAG, "Trying ${bondedDevices.size} bonded device(s)")
Thread {
var result: String? = null
var status = SyncStatus.PHONE_NOT_REACHABLE
for (device in bondedDevices) {
val deviceName = try { device.name } catch (_: SecurityException) { "unknown" }
Log.d(TAG, "Trying $deviceName")
val fetchResult = fetchCardsFromDevice(device, deviceName)
if (fetchResult.second != SyncStatus.PHONE_NOT_REACHABLE) {
result = fetchResult.first
status = fetchResult.second
break
}
}
if (status == SyncStatus.PHONE_NOT_REACHABLE) {
Log.w(TAG, "Bluetooth sync failed, phone not reachable")
}
onResult(result, status)
}.start()
}
private fun fetchCardsFromDevice(
device: android.bluetooth.BluetoothDevice,
deviceName: String
): Pair<String?, SyncStatus> {
var socket: BluetoothSocket? = null
return try {
socket = device.createRfcommSocketToServiceRecord(WearBluetoothProtocol.BT_SERVICE_UUID)
socket.connect()
val supportedVersions = requestSupportedVersions(socket)
?: return null to SyncStatus.PHONE_NOT_REACHABLE
if (WearBluetoothProtocol.PROTOCOL_VERSION !in supportedVersions) {
Log.w(TAG, "Phone does not support API version ${WearBluetoothProtocol.PROTOCOL_VERSION}")
return null to SyncStatus.WATCH_OUTDATED
}
socket.close()
socket = null
Log.d(TAG, "Connected to $deviceName with API version ${WearBluetoothProtocol.PROTOCOL_VERSION}")
val allCards = JSONArray()
var pageIndex = 0
var totalPages = 1
while (pageIndex < totalPages) {
socket = device.createRfcommSocketToServiceRecord(WearBluetoothProtocol.BT_SERVICE_UUID)
socket.connect()
val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
writer.print("${WearBluetoothProtocol.BT_CMD_CARDS_PAGE_PREFIX}$pageIndex\n")
writer.flush()
val json = reader.readLine()?.trim() ?: ""
if (json.isEmpty()) {
Log.w(TAG, "Empty response for page $pageIndex from $deviceName")
return null to SyncStatus.PHONE_NOT_REACHABLE
}
val parsed = parsePageResponse(json, pageIndex)
if (parsed.second != SyncStatus.OK) {
return null to parsed.second
}
val pageCards = JSONArray(parsed.first!!)
for (i in 0 until pageCards.length()) {
allCards.put(pageCards.getJSONObject(i))
}
if (pageIndex == 0) {
totalPages = parsed.third
Log.d(TAG, "Total pages: $totalPages")
}
socket.close()
socket = null
pageIndex++
}
Log.d(TAG, "Fetched ${allCards.length()} cards in $totalPages page(s) from $deviceName")
allCards.toString() to SyncStatus.OK
} catch (e: Exception) {
Log.d(TAG, "Failed to connect to $deviceName: ${e.message}")
null to SyncStatus.PHONE_NOT_REACHABLE
} finally {
try { socket?.close() } catch (_: Exception) { }
}
}
private fun requestSupportedVersions(socket: BluetoothSocket): Set<Int>? {
val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false)
val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8"))
writer.print("${WearBluetoothProtocol.BT_CMD_VERSIONS}\n")
writer.flush()
val response = reader.readLine()?.trim() ?: return null
return try {
val versions = JSONArray(response)
buildSet {
for (index in 0 until versions.length()) {
add(versions.getInt(index))
}
}
} catch (e: Exception) {
Log.w(TAG, "Invalid versions response from phone", e)
null
}
}
private fun parsePageResponse(json: String, expectedPage: Int): Triple<String?, SyncStatus, Int> {
return try {
val obj = JSONObject(json)
val cards = obj.getJSONArray("cards").toString()
val totalPages = obj.optInt("totalPages", 1)
Triple(cards, SyncStatus.OK, totalPages)
} catch (_: Exception) {
Log.w(TAG, "Unparseable response from phone for page $expectedPage")
Triple(null, SyncStatus.PHONE_NOT_REACHABLE, 1)
}
}
}

View File

@@ -0,0 +1,111 @@
package me.hackerchick.catima.wear
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import me.hackerchick.catima.wear.ui.CardListScreen
import me.hackerchick.catima.wear.ui.CardViewScreen
import me.hackerchick.catima.wear.ui.theme.CatimaWearTheme
import protect.card_locker.shared.BluetoothPermissionHelper
class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "CatimaWear"
}
@Volatile private var fetchInFlight = false
private var protocolIncompatible = false
private val btPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
requestCardsFromPhone()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WearCardRepository.loadCache(this)
setContent {
CatimaWearTheme {
val navController = rememberSwipeDismissableNavController()
val cards by WearCardRepository.cards.collectAsState()
val syncStatus by WearCardRepository.syncStatus.collectAsState()
SwipeDismissableNavHost(
navController = navController,
startDestination = "card_list"
) {
composable("card_list") {
CardListScreen(
cards = cards,
syncStatus = syncStatus,
onCardClick = { card ->
navController.navigate("card_view/${card.id}")
}
)
}
composable(
route = "card_view/{cardId}",
arguments = listOf(navArgument("cardId") { type = NavType.IntType })
) { backStackEntry ->
val cardId = backStackEntry.arguments?.getInt("cardId") ?: return@composable
val card = cards?.find { it.id == cardId }
CardViewScreen(card = card)
}
}
}
}
}
override fun onResume() {
super.onResume()
maybeRequestCards()
}
private fun maybeRequestCards() {
if (fetchInFlight || protocolIncompatible) return
BluetoothPermissionHelper.requestBluetoothConnectIfNeeded(
this,
btPermissionLauncher
) {
requestCardsFromPhone()
}
}
private fun requestCardsFromPhone() {
fetchInFlight = true
WearCardRepository.setSyncStatus(SyncStatus.SYNCING)
BluetoothCardClient.fetchCards(this) { json, status ->
fetchInFlight = false
when (status) {
SyncStatus.OK -> {
if (json != null) {
Log.d(TAG, "Got cards via Bluetooth")
WearCardRepository.updateCards(this, json)
} else {
WearCardRepository.setSyncStatus(SyncStatus.PHONE_NOT_REACHABLE)
}
}
SyncStatus.WATCH_OUTDATED -> {
Log.w(TAG, "Wear app is outdated")
protocolIncompatible = true
WearCardRepository.setSyncStatus(status)
}
else -> WearCardRepository.setSyncStatus(status)
}
}
}
}

View File

@@ -0,0 +1,15 @@
package me.hackerchick.catima.wear
import androidx.annotation.StringRes
enum class SyncStatus(@StringRes val labelRes: Int?) {
LOADING(null),
SYNCING(R.string.syncing),
PHONE_NOT_REACHABLE(R.string.sync_failed),
PHONE_OUTDATED(R.string.phone_outdated),
WATCH_OUTDATED(R.string.watch_outdated),
PERMISSION_DENIED(R.string.bluetooth_permission_denied),
BLUETOOTH_DISABLED(R.string.bluetooth_disabled),
SYNC_ERROR(R.string.sync_error),
OK(null),
}

View File

@@ -0,0 +1,44 @@
package me.hackerchick.catima.wear
import org.json.JSONArray
import org.json.JSONObject
data class WearCard(
val id: Int,
val store: String,
val cardId: String,
val barcodeId: String?,
val barcodeType: String?,
val headerColor: Int?,
) {
fun toJson(): JSONObject = JSONObject().apply {
put("id", id)
put("store", store)
put("cardId", cardId)
put("barcodeId", barcodeId ?: JSONObject.NULL)
put("barcodeType", barcodeType ?: JSONObject.NULL)
put("headerColor", headerColor ?: JSONObject.NULL)
}
companion object {
fun fromJson(json: JSONObject): WearCard = WearCard(
id = json.getInt("id"),
store = json.getString("store"),
cardId = json.getString("cardId"),
barcodeId = if (json.isNull("barcodeId")) null else json.optString("barcodeId").takeIf { it.isNotEmpty() },
barcodeType = if (json.isNull("barcodeType")) null else json.optString("barcodeType").takeIf { it.isNotEmpty() },
headerColor = if (json.isNull("headerColor")) null else json.getInt("headerColor"),
)
fun listToJson(cards: List<WearCard>): String {
val array = JSONArray()
cards.forEach { array.put(it.toJson()) }
return array.toString()
}
fun listFromJson(json: String): List<WearCard> {
val array = JSONArray(json)
return (0 until array.length()).map { fromJson(array.getJSONObject(it)) }
}
}
}

View File

@@ -0,0 +1,42 @@
package me.hackerchick.catima.wear
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object WearCardRepository {
private const val TAG = "WearCardRepository"
private val _cards = MutableStateFlow<List<WearCard>?>(null)
val cards: StateFlow<List<WearCard>?> = _cards.asStateFlow()
private val _syncStatus = MutableStateFlow(SyncStatus.LOADING)
val syncStatus: StateFlow<SyncStatus> = _syncStatus.asStateFlow()
fun loadCache(context: Context) {
val cached = WearCardStore.load(context)
if (cached != null && _cards.value == null) {
_cards.value = cached
Log.d(TAG, "Loaded ${cached.size} cards from cache")
}
}
fun setSyncStatus(status: SyncStatus) {
_syncStatus.value = status
}
fun updateCards(context: Context, json: String) {
try {
val incoming = WearCard.listFromJson(json)
_cards.value = incoming
_syncStatus.value = SyncStatus.OK
WearCardStore.save(context, incoming)
Log.d(TAG, "Cards updated: ${incoming.size} cards")
} catch (e: Exception) {
Log.e(TAG, "Failed to parse cards JSON", e)
_syncStatus.value = SyncStatus.SYNC_ERROR
}
}
}

View File

@@ -0,0 +1,23 @@
package me.hackerchick.catima.wear
import android.content.Context
import androidx.core.content.edit
object WearCardStore {
private const val PREFS_NAME = "catima_wear_cards"
private const val KEY_CARDS_JSON = "cards_json"
fun load(context: Context): List<WearCard>? {
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<WearCard>) {
val json = WearCard.listToJson(cards)
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit {
putString(KEY_CARDS_JSON, json)
}
}
}

View File

@@ -0,0 +1,104 @@
package me.hackerchick.catima.wear.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.CircularProgressIndicator
import androidx.wear.compose.material.Text
import me.hackerchick.catima.wear.R
import me.hackerchick.catima.wear.SyncStatus
import me.hackerchick.catima.wear.WearCard
@Composable
fun CardListScreen(
cards: List<WearCard>?,
syncStatus: SyncStatus,
onCardClick: (WearCard) -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center,
) {
when {
cards == null && syncStatus == SyncStatus.PHONE_NOT_REACHABLE -> {
Text(
text = stringResource(R.string.phone_not_connected),
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp),
)
}
cards == null && syncStatus.labelRes != null -> {
Text(
text = stringResource(syncStatus.labelRes),
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp),
)
}
cards == null -> {
CircularProgressIndicator()
}
cards.isEmpty() && syncStatus != SyncStatus.SYNCING -> {
Text(
text = stringResource(R.string.no_cards),
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp),
)
}
else -> {
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(cards, key = { it.id }) { card ->
Chip(
modifier = Modifier.fillMaxWidth(),
label = {
Text(
text = card.store,
maxLines = 1,
)
},
onClick = { onCardClick(card) },
colors = if (card.headerColor != null) {
ChipDefaults.chipColors(backgroundColor = Color(card.headerColor))
} else {
ChipDefaults.primaryChipColors()
},
)
}
val footerLabel = syncStatus.labelRes
if (footerLabel != null) {
item {
Text(
text = stringResource(footerLabel),
textAlign = TextAlign.Center,
fontSize = 11.sp,
color = Color.Gray,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,174 @@
package me.hackerchick.catima.wear.ui
import android.graphics.Bitmap
import android.graphics.Color
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.CircularProgressIndicator
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import me.hackerchick.catima.wear.R
import me.hackerchick.catima.wear.WearCard
@Composable
fun CardViewScreen(card: WearCard?) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center,
) {
if (card == null) {
CircularProgressIndicator()
} else {
CardDetail(card = card)
}
}
}
@Composable
private fun KeepScreenOnAtMaxBrightness() {
val activity = LocalActivity.current ?: return
DisposableEffect(Unit) {
val window = activity.window
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val params = window.attributes
params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
window.attributes = params
onDispose {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val restore = window.attributes
restore.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
window.attributes = restore
}
}
}
@Composable
private fun CardDetail(card: WearCard) {
val barcodeValue = card.barcodeId ?: card.cardId
val barcodeType = card.barcodeType
KeepScreenOnAtMaxBrightness()
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(ComposeColor.White)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
val screenSize = minOf(maxWidth, maxHeight)
val roundInset = screenSize * 0.15f
val barcodeMaxSize = screenSize - roundInset * 2
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = card.store,
textAlign = TextAlign.Center,
maxLines = 2,
color = ComposeColor.Black,
)
Spacer(modifier = Modifier.height(8.dp))
if (barcodeType != null) {
val barcodeResult = remember(barcodeValue, barcodeType) {
generateBarcode(barcodeValue, barcodeType)
}
if (barcodeResult != null) {
Image(
bitmap = barcodeResult.bitmap.asImageBitmap(),
contentDescription = card.cardId,
modifier = if (barcodeResult.isSquare) {
Modifier.size(barcodeMaxSize)
} else {
Modifier.fillMaxWidth(0.85f).height(60.dp)
},
)
} else {
Text(
text = stringResource(R.string.no_barcode),
textAlign = TextAlign.Center,
color = ComposeColor.Black,
)
}
} else {
Text(
text = stringResource(R.string.no_barcode),
textAlign = TextAlign.Center,
color = ComposeColor.Black,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = barcodeValue,
textAlign = TextAlign.Center,
maxLines = 2,
color = ComposeColor.Black,
)
}
}
}
private data class BarcodeResult(val bitmap: Bitmap, val isSquare: Boolean)
private fun isBarcodeSquare(format: BarcodeFormat): Boolean =
format == BarcodeFormat.QR_CODE
|| format == BarcodeFormat.AZTEC
|| format == BarcodeFormat.DATA_MATRIX
private fun generateBarcode(value: String, formatName: String): BarcodeResult? {
return try {
val format = BarcodeFormat.valueOf(formatName)
val isSquare = isBarcodeSquare(format)
val width = if (isSquare) 300 else 600
val height = if (isSquare) 300 else 150
val hints = mapOf(EncodeHintType.MARGIN to 0)
val bitMatrix = MultiFormatWriter().encode(value, format, width, height, hints)
val pixels = IntArray(width * height) { index ->
val x = index % width
val y = index / width
if (bitMatrix[x, y]) Color.BLACK else Color.WHITE
}
BarcodeResult(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888), isSquare)
} catch (_: Exception) {
null
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#1F4262"/>
</vector>

View File

@@ -0,0 +1,59 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.75"
android:scaleY="0.75"
android:translateX="13.5"
android:translateY="13.5">
<path
android:pathData="M45.5,30.58L68.05,22.37C70.13,21.61 72.42,22.68 73.18,24.76L75.92,32.28L49.6,41.85L45.5,30.58Z"
android:fillColor="#F5A3A3"/>
<path
android:pathData="M70.36,25.78C70.17,25.27 69.6,25 69.08,25.19L49.35,32.37L51.4,38.01L72.07,30.48L70.36,25.78ZM75.92,32.28L49.6,41.85L45.5,30.58L68.05,22.37C70.13,21.61 72.42,22.68 73.18,24.76L75.92,32.28Z"
android:fillColor="#CF1717"/>
<path
android:pathData="M58.42,30.58L35.86,22.37C33.79,21.61 31.49,22.68 30.74,24.76L28,32.28L54.31,41.85L58.42,30.58Z"
android:fillColor="#F5A3A3"/>
<path
android:pathData="M33.56,25.78C33.74,25.27 34.32,25 34.84,25.19L54.57,32.37L52.52,38.01L31.84,30.48L33.56,25.78ZM28,32.28L54.31,41.85L58.42,30.58L35.86,22.37C33.79,21.61 31.49,22.68 30.74,24.76L28,32.28Z"
android:fillColor="#DD1818"/>
<path
android:pathData="M28.7,37.6C29.08,35.42 31.15,33.97 33.33,34.35L80.6,42.69C82.78,43.07 84.23,45.15 83.85,47.32L81.82,58.83C82.3,59.1 84.67,60.16 87.42,57.23V57.23C87.92,56.69 88.45,56.23 89.01,55.86C91.76,54.02 94,55.22 94,58.53C94,61.34 92.39,64.78 90.21,66.88C86.63,70.54 80.69,70.56 79.75,70.53L77.59,82.78C77.21,84.95 75.14,86.4 72.96,86.02L25.69,77.69C25.67,77.68 25.66,77.68 25.64,77.68C23.45,77.32 22,76.7 22,76C22,75.72 22.23,75.45 22.66,75.2C22.4,74.54 22.31,73.8 22.44,73.05L28.7,37.6Z"
android:fillColor="#B81414"/>
<path
android:pathData="M90.67,60.75C90.67,61.85 89.93,63.24 89.01,63.86C88.09,64.47 87.34,64.07 87.34,62.97C87.34,61.86 88.09,60.47 89.01,59.86C89.93,59.24 90.67,59.64 90.67,60.75Z"
android:fillColor="#E82E2E"/>
<path
android:pathData="M78,30C80.21,30 82,31.79 82,34V70C82,72.21 80.21,74 78,74H30C25.58,74 22,74.9 22,76V32C22,30.9 25.58,30 30,30H78Z"
android:fillColor="#E82E2E"/>
<path
android:pathData="M51.2,54.25C51.62,53.53 52.53,53.29 53.25,53.7C53.94,54.1 54.2,54.98 53.84,55.68L53.76,55.82C53.4,56.52 53.65,57.4 54.35,57.8C55.04,58.2 55.93,57.98 56.36,57.32L56.44,57.18C56.87,56.52 57.75,56.3 58.45,56.7C59.16,57.12 59.41,58.03 58.99,58.75C57.75,60.9 55,61.64 52.85,60.4C50.7,59.15 49.96,56.4 51.2,54.25Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M52.79,54.25C52.38,53.53 51.46,53.29 50.75,53.7C50.05,54.1 49.8,54.98 50.16,55.68L50.23,55.82C50.6,56.52 50.34,57.4 49.65,57.8C48.95,58.2 48.07,57.98 47.64,57.32L47.56,57.18C47.13,56.52 46.24,56.3 45.55,56.7C44.83,57.12 44.59,58.03 45,58.75C46.24,60.9 49,61.64 51.15,60.4C53.3,59.15 54.04,56.4 52.79,54.25Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M53.3,56.75C52.72,57.75 51.28,57.75 50.7,56.75L48.1,52.25C47.53,51.25 48.25,50 49.4,50L54.6,50C55.75,50 56.47,51.25 55.9,52.25L53.3,56.75Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M40.5,40.5C43.73,40.5 46.46,42.62 47.42,45.53C47.68,46.31 47.26,47.16 46.47,47.42C45.69,47.68 44.84,47.26 44.58,46.47C44,44.73 42.38,43.5 40.5,43.5C38.62,43.5 37,44.73 36.42,46.47C36.16,47.26 35.31,47.68 34.53,47.42C33.74,47.16 33.32,46.31 33.58,45.53C34.54,42.62 37.27,40.5 40.5,40.5Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M63.5,40.5C66.73,40.5 69.46,42.62 70.42,45.53C70.68,46.31 70.26,47.16 69.47,47.42C68.69,47.68 67.84,47.26 67.58,46.47C67,44.73 65.38,43.5 63.5,43.5C61.62,43.5 60,44.73 59.42,46.47C59.16,47.26 58.31,47.68 57.53,47.42C56.74,47.16 56.32,46.31 56.58,45.53C57.54,42.62 60.27,40.5 63.5,40.5Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M26,55C25.45,55 25,54.55 25,54C25,53.45 25.45,53 26,53H42C42.55,53 43,53.45 43,54C43,54.55 42.55,55 42,55H26Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M26.35,60.94C25.83,61.13 25.26,60.87 25.06,60.35C24.87,59.83 25.13,59.26 25.65,59.06L41.65,53.06C42.17,52.87 42.74,53.13 42.94,53.65C43.13,54.17 42.87,54.74 42.35,54.94L26.35,60.94Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M61.65,54.94C61.13,54.74 60.87,54.17 61.06,53.65C61.26,53.13 61.83,52.87 62.35,53.06L78.35,59.06C78.87,59.26 79.13,59.83 78.94,60.35C78.74,60.87 78.17,61.13 77.65,60.94L61.65,54.94Z"
android:fillColor="#8A0F0F"/>
<path
android:pathData="M78,55C78.55,55 79,54.55 79,54C79,53.45 78.55,53 78,53H62C61.45,53 61,53.45 61,54C61,54.55 61.45,55 62,55H78Z"
android:fillColor="#8A0F0F"/>
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<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, your watch is connected and WearOS is enabled in Catima settings.</string>
<string name="loading_cards">Loading cards…</string>
<string name="no_barcode">No barcode available for this card.</string>
<string name="card_id_label">Card ID</string>
<string name="syncing">Syncing with phone…</string>
<string name="sync_failed">Offline showing cached cards</string>
<string name="phone_outdated">Catima on your phone is outdated.\nPlease update it.</string>
<string name="watch_outdated">This watch app is outdated.\nPlease update it.</string>
<string name="bluetooth_permission_denied">Bluetooth permission denied.\nGrant it in Settings to sync cards.</string>
<string name="bluetooth_disabled">Bluetooth is turned off.\nEnable it to sync cards from your phone.</string>
<string name="sync_error">Failed to read cards from your phone.\nTry syncing again.</string>
</resources>