diff --git a/.gitignore b/.gitignore index 538763b64b..efd2864137 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ /app/*.log /app/build /app/release +/wear/build +/wear/release /.idea/* !/.idea/icon.svg # Bundle diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed2c6f41ff..ec2004a7be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,11 @@ + + + + + + + diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java index 8865ae2a12..fbf2ff1bda 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardLockerApplication.java @@ -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; @@ -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 { @@ -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)); + } } } diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index d153823f59..0a1ad374d4 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -467,11 +467,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { ShortcutHelper.updateShortcuts(mAdapter.mContext) } - private fun processParseResultList( - parseResultList: MutableList, - group: String?, - closeAppOnNoBarcode: Boolean - ) { + private fun processParseResultList(parseResultList: MutableList) { require(!parseResultList.isEmpty()) { "parseResultList may not be empty" } Utils.makeUserChooseParseResultFromList( @@ -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() } }) } @@ -556,7 +547,7 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { return } - processParseResultList(parseResultList, null, true) + processParseResultList(parseResultList) } private fun extractIntentFields(intent: Intent) { diff --git a/app/src/main/java/protect/card_locker/NotificationInfo.kt b/app/src/main/java/protect/card_locker/NotificationInfo.kt new file mode 100644 index 0000000000..dae1d1434b --- /dev/null +++ b/app/src/main/java/protect/card_locker/NotificationInfo.kt @@ -0,0 +1,8 @@ +package protect.card_locker + +object NotificationInfo { + object WearBluetooth { + const val NOTIFICATION_ID = 1001 + const val CHANNEL_ID = "catima_wear_bt" + } +} diff --git a/app/src/main/java/protect/card_locker/preferences/Settings.java b/app/src/main/java/protect/card_locker/preferences/Settings.java index f77f4b359f..39014cec3c 100644 --- a/app/src/main/java/protect/card_locker/preferences/Settings.java +++ b/app/src/main/java/protect/card_locker/preferences/Settings.java @@ -112,4 +112,8 @@ public int getPreferredColumnCount() { public boolean useVolumeKeysForNavigation() { return getBoolean(R.string.settings_key_use_volume_keys_navigation, false); } + + public boolean getWearSyncEnabled() { + return getBoolean(R.string.settings_key_wear_sync, false); + } } diff --git a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt index 8d48320d22..dd5c72e2d7 100644 --- a/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt +++ b/app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt @@ -1,22 +1,26 @@ package protect.card_locker.preferences -import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.MenuItem import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat import protect.card_locker.BuildConfig import protect.card_locker.CatimaAppCompatActivity import protect.card_locker.MainActivity import protect.card_locker.R import protect.card_locker.Utils import protect.card_locker.databinding.SettingsActivityBinding +import protect.card_locker.wearos.BluetoothServerService class SettingsActivity : CatimaAppCompatActivity() { @@ -81,6 +85,25 @@ class SettingsActivity : CatimaAppCompatActivity() { class SettingsFragment : PreferenceFragmentCompat() { var mReloadMain: Boolean = false + private val mBtPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted && Settings(requireContext()).wearSyncEnabled) { + requireContext().startService(Intent(requireContext(), BluetoothServerService::class.java)) + } + } + + override fun onResume() { + super.onResume() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + Settings(requireContext()).wearSyncEnabled && + ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) + } + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { // Load the preferences from an XML resource addPreferencesFromResource(R.xml.preferences) @@ -104,7 +127,7 @@ class SettingsActivity : CatimaAppCompatActivity() { val oledDarkPreference = findPreference(getString(R.string.settings_key_oled_dark)) oledDarkPreference!!.setOnPreferenceChangeListener { _, _ -> - refreshActivity(true) + refreshActivity() true } @@ -148,7 +171,7 @@ class SettingsActivity : CatimaAppCompatActivity() { localePreference.setOnPreferenceChangeListener { _, newValue -> // See corresponding comment in Utils.updateBaseContextLocale for Android 6- notes if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - refreshActivity(true) + refreshActivity() return@setOnPreferenceChangeListener true } val newLocale = newValue as String @@ -157,13 +180,32 @@ class SettingsActivity : CatimaAppCompatActivity() { true } + val wearSyncPreference = findPreference(getString(R.string.settings_key_wear_sync)) + wearSyncPreference!!.setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + val ctx = requireContext() + if (enabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + mBtPermissionLauncher.launch(android.Manifest.permission.BLUETOOTH_CONNECT) + } else { + ctx.startService(Intent(ctx, BluetoothServerService::class.java)) + } + } else { + ctx.stopService(Intent(ctx, BluetoothServerService::class.java)) + } + true + } + // Hide crash reporter settings on builds it's not enabled on val crashReporterPreference = findPreference("acra.enable") crashReporterPreference!!.isVisible = BuildConfig.useAcraCrashReporter } - private fun refreshActivity(reloadMain: Boolean) { - mReloadMain = reloadMain || mReloadMain + private fun refreshActivity() { + mReloadMain = true activity?.recreate() } } diff --git a/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt new file mode 100644 index 0000000000..4e843cba84 --- /dev/null +++ b/app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt @@ -0,0 +1,206 @@ +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.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import org.json.JSONArray +import org.json.JSONObject +import protect.card_locker.DBHelper +import protect.card_locker.NotificationInfo +import protect.card_locker.R +import protect.card_locker.Utils +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.io.PrintWriter +import java.util.UUID + +class BluetoothServerService : Service() { + + companion object { + private const val TAG = "CatimaBtServer" + private const val BT_SERVICE_NAME = "CatimaWear" + val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") + private const val PROTOCOL_VERSION = 1 + private const val CMD_V1_CARDS_REQUEST = "V1/CARDS_REQUEST" + 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 (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + 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 (ContextCompat.checkSelfPermission( + this@BluetoothServerService, + android.Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + ) { + serverSocket = adapter.listenUsingRfcommWithServiceRecord(BT_SERVICE_NAME, BT_SERVICE_UUID) + } else { + Log.w(TAG, "BLUETOOTH_CONNECT permission missing, cannot open server socket") + running = false + } + } catch (e: Exception) { + Log.e(TAG, "Failed to create server socket", e) + running = false + } + } + + 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 != null && command.startsWith("V1/")) { + when (command) { + CMD_V1_CARDS_REQUEST -> { + val json = buildCardsJson() + writer.println(json) + writer.flush() + Log.d(TAG, "Sent ${json.length} bytes to $deviceName") + } + else -> Log.w(TAG, "Unsupported V1 command: $command from $deviceName") + } + } 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 buildCardsJson(): String { + return JSONObject().apply { + put("version", PROTOCOL_VERSION) + put("cards", buildCardsArray()) + }.toString() + } + + private fun buildCardsArray(): JSONArray { + 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 array = JSONArray() + cursor.use { + val idIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID) + val storeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE) + val cardIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.CARD_ID) + val barcodeIdIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_ID) + val barcodeTypeIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE) + val headerColorIdx = it.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.HEADER_COLOR) + while (it.moveToNext()) { + array.put(JSONObject().apply { + put("id", it.getInt(idIdx)) + put("store", it.getString(storeIdx) ?: "") + put("cardId", it.getString(cardIdIdx) ?: "") + put("barcodeId", if (it.isNull(barcodeIdIdx)) JSONObject.NULL else it.getString(barcodeIdIdx)) + put("barcodeType", if (it.isNull(barcodeTypeIdx)) JSONObject.NULL else it.getString(barcodeTypeIdx)) + put("headerColor", if (it.isNull(headerColorIdx)) JSONObject.NULL else it.getInt(headerColorIdx)) + }) + } + } + return array + } + + fun cancel() { + running = false + try { serverSocket?.close() } catch (_: Exception) { } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cbdea0793..1d17ab4895 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,6 +302,10 @@ pref_column_count_landscape General Privacy + Wear OS + pref_wear_sync + Sync with Wear OS + Allow a Wear OS companion app to read your cards over Bluetooth Display options Show archived cards View online @@ -351,4 +355,6 @@ NFC is paused Change settings Failed to pause NFC + Wear OS companion + Catima watch sync active diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index fec2bce50f..37d407c492 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -134,4 +134,18 @@ app:singleLineTitle="false" /> + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index e6d03252e1..929fcc46bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,9 @@ plugins { alias(libs.plugins.com.android.application) apply false + alias(libs.plugins.com.android.library) apply false alias(libs.plugins.org.jetbrains.kotlin.android) apply false + alias(libs.plugins.org.jetbrains.kotlin.plugin.compose) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d1841f2ad..d746279437 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,9 @@ acra = "5.13.1" # Testing androidXTest = "1.7.0" +# Wear OS +wearCompose = "1.6.2" + [libraries] # AndroidX androidx-appcompat-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" } @@ -42,6 +45,12 @@ net-lingala-zip4j-zip4j = { group = "net.lingala.zip4j", name = "zip4j", version ch-acra-acra-mail = { group = "ch.acra", name = "acra-mail", version.ref = "acra" } ch-acra-acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = "acra" } +# Wear OS +androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" } +androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" } +androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" } + + # Testing androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidXTest" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTest" } @@ -54,6 +63,7 @@ org-robolectric-robolectric = { group = "org.robolectric", name = "robolectric", [plugins] com-android-application = { id = "com.android.application", version = "9.2.1" } +com-android-library = { id = "com.android.library", version = "9.2.1" } org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } org-jetbrains-kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ce82c4000..aba0d3ef15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,4 @@ dependencyResolutionManagement { } rootProject.name = "Catima" include(":app") +include(":wear") diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 0000000000..378f2121b2 --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.com.android.application) + alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.org.jetbrains.kotlin.plugin.compose) +} + +kotlin { + jvmToolchain(21) +} + +android { + namespace = "me.hackerchick.catima.wear" + compileSdk = 36 + + defaultConfig { + applicationId = "me.hackerchick.catima.wear" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + debug { + applicationIdSuffix = ".debug" + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +dependencies { + implementation(libs.androidx.core.core.ktx) + implementation(libs.androidx.activity.activity.compose) + + // Wear OS Compose + implementation(libs.androidx.wear.compose.material) + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.navigation) + + // ZXing for barcode rendering + implementation(libs.com.google.zxing.core) +} diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 0000000000..da1f1d46e9 --- /dev/null +++ b/wear/proguard-rules.pro @@ -0,0 +1 @@ +-keep class me.hackerchick.catima.wear.** { *; } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..5e10a1782f --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt new file mode 100644 index 0000000000..3284772268 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/BluetoothCardClient.kt @@ -0,0 +1,116 @@ +package me.hackerchick.catima.wear + +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.content.ContextCompat +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.io.PrintWriter + +object BluetoothCardClient { + + private const val TAG = "CatimaBtClient" + + enum class FetchStatus { + SUCCESS, + PHONE_OUTDATED, + WATCH_OUTDATED, + NO_DEVICE, + PERMISSION_DENIED, + BLUETOOTH_DISABLED + } + + fun fetchCards(context: Context, onResult: (cards: String?, status: FetchStatus) -> Unit) { + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + Log.w(TAG, "BLUETOOTH_CONNECT permission not granted") + onResult(null, FetchStatus.PERMISSION_DENIED) + return + } + + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + if (adapter == null || !adapter.isEnabled) { + Log.w(TAG, "Bluetooth not available or disabled") + onResult(null, FetchStatus.BLUETOOTH_DISABLED) + return + } + + val bondedDevices = adapter.bondedDevices + if (bondedDevices.isEmpty()) { + Log.w(TAG, "No bonded devices found") + onResult(null, FetchStatus.NO_DEVICE) + return + } + + Log.d(TAG, "Trying ${bondedDevices.size} bonded device(s)") + + Thread { + var result: String? = null + var status = FetchStatus.NO_DEVICE + for (device in bondedDevices) { + val deviceName = try { device.name } catch (_: Exception) { "unknown" } + Log.d(TAG, "Trying $deviceName") + var socket: BluetoothSocket? = null + try { + socket = device.createRfcommSocketToServiceRecord(WearProtocol.BT_SERVICE_UUID) + socket.connect() + Log.d(TAG, "Connected to $deviceName") + + val writer = PrintWriter(OutputStreamWriter(socket.outputStream, "UTF-8"), false) + val reader = BufferedReader(InputStreamReader(socket.inputStream, "UTF-8")) + + writer.print(WearProtocol.BT_CMD_CARDS_REQUEST_V1) + writer.flush() + + val json = reader.readLine()?.trim() ?: "" + if (json.isNotEmpty()) { + Log.d(TAG, "Received ${json.length} bytes from $deviceName") + val parsed = parseResponse(json) + result = parsed.first + status = parsed.second + break + } + } catch (e: Exception) { + Log.d(TAG, "Failed to connect to $deviceName: ${e.message}") + } finally { + try { socket?.close() } catch (_: Exception) { } + } + } + + if (status == FetchStatus.NO_DEVICE) { + Log.w(TAG, "No Catima phone found among bonded devices") + } + onResult(result, status) + }.start() + } + + private fun parseResponse(json: String): Pair { + return try { + val obj = JSONObject(json) + val version = obj.getInt("version") + if (version != WearProtocol.PROTOCOL_VERSION) { + Log.w(TAG, "Unsupported protocol version: $version") + null to FetchStatus.WATCH_OUTDATED + } else { + val cards = obj.getJSONArray("cards").toString() + cards to FetchStatus.SUCCESS + } + } catch (_: Exception) { + try { + JSONArray(json) + Log.w(TAG, "Phone app returned a legacy response; update Catima on your phone") + null to FetchStatus.PHONE_OUTDATED + } catch (_: Exception) { + Log.w(TAG, "Unparseable response from phone") + null to FetchStatus.NO_DEVICE + } + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt new file mode 100644 index 0000000000..244638325b --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt @@ -0,0 +1,141 @@ +package me.hackerchick.catima.wear + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.navigation.NavType +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 + +class MainActivity : ComponentActivity() { + + companion object { + private const val TAG = "CatimaWear" + } + + @Volatile private var fetchInFlight = 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 syncing by WearCardRepository.syncing.collectAsState() + val phoneNotReachable by WearCardRepository.phoneNotReachable.collectAsState() + val phoneOutdated by WearCardRepository.phoneOutdated.collectAsState() + val watchOutdated by WearCardRepository.watchOutdated.collectAsState() + val permissionDenied by WearCardRepository.permissionDenied.collectAsState() + val bluetoothDisabled by WearCardRepository.bluetoothDisabled.collectAsState() + val syncError by WearCardRepository.syncError.collectAsState() + + SwipeDismissableNavHost( + navController = navController, + startDestination = "card_list" + ) { + composable("card_list") { + CardListScreen( + cards = cards, + syncing = syncing, + phoneNotReachable = phoneNotReachable, + phoneOutdated = phoneOutdated, + watchOutdated = watchOutdated, + permissionDenied = permissionDenied, + bluetoothDisabled = bluetoothDisabled, + syncError = syncError, + 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) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) + } else { + requestCardsFromPhone() + } + } + + private fun requestCardsFromPhone() { + fetchInFlight = true + WearCardRepository.setSyncing(true) + BluetoothCardClient.fetchCards(this) { json, status -> + fetchInFlight = false + when (status) { + BluetoothCardClient.FetchStatus.SUCCESS -> { + if (json != null) { + Log.d(TAG, "Got cards via Bluetooth") + WearCardRepository.updateCards(this, json) + } else { + WearCardRepository.setPhoneNotReachable() + } + } + BluetoothCardClient.FetchStatus.PHONE_OUTDATED -> { + Log.w(TAG, "Phone app is outdated") + WearCardRepository.setPhoneOutdated() + } + BluetoothCardClient.FetchStatus.WATCH_OUTDATED -> { + Log.w(TAG, "Wear app is outdated") + WearCardRepository.setWatchOutdated() + } + BluetoothCardClient.FetchStatus.NO_DEVICE -> { + Log.w(TAG, "Bluetooth failed, phone not reachable") + WearCardRepository.setPhoneNotReachable() + } + BluetoothCardClient.FetchStatus.PERMISSION_DENIED -> { + Log.w(TAG, "Bluetooth permission denied") + WearCardRepository.setPermissionDenied() + } + BluetoothCardClient.FetchStatus.BLUETOOTH_DISABLED -> { + Log.w(TAG, "Bluetooth is disabled") + WearCardRepository.setBluetoothDisabled() + } + } + } + } + +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt new file mode 100644 index 0000000000..d60de54ce0 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt @@ -0,0 +1,44 @@ +package me.hackerchick.catima.wear + +import org.json.JSONArray +import org.json.JSONObject + +data class WearCard( + val id: Int, + val store: String, + val cardId: String, + val barcodeId: String?, + val barcodeType: String?, + val headerColor: Int?, +) { + fun toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("store", store) + put("cardId", cardId) + put("barcodeId", barcodeId ?: JSONObject.NULL) + put("barcodeType", barcodeType ?: JSONObject.NULL) + put("headerColor", headerColor ?: JSONObject.NULL) + } + + companion object { + fun fromJson(json: JSONObject): WearCard = WearCard( + id = json.getInt("id"), + store = json.getString("store"), + cardId = json.getString("cardId"), + barcodeId = 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): String { + val array = JSONArray() + cards.forEach { array.put(it.toJson()) } + return array.toString() + } + + fun listFromJson(json: String): List { + val array = JSONArray(json) + return (0 until array.length()).map { fromJson(array.getJSONObject(it)) } + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt new file mode 100644 index 0000000000..6e560048c6 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardRepository.kt @@ -0,0 +1,119 @@ +package me.hackerchick.catima.wear + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object WearCardRepository { + private const val TAG = "WearCardRepository" + + private val _cards = MutableStateFlow?>(null) + val cards: StateFlow?> = _cards.asStateFlow() + + private val _syncing = MutableStateFlow(false) + val syncing: StateFlow = _syncing.asStateFlow() + + private val _phoneNotReachable = MutableStateFlow(false) + val phoneNotReachable: StateFlow = _phoneNotReachable.asStateFlow() + + private val _phoneOutdated = MutableStateFlow(false) + val phoneOutdated: StateFlow = _phoneOutdated.asStateFlow() + + private val _watchOutdated = MutableStateFlow(false) + val watchOutdated: StateFlow = _watchOutdated.asStateFlow() + + private val _permissionDenied = MutableStateFlow(false) + val permissionDenied: StateFlow = _permissionDenied.asStateFlow() + + private val _bluetoothDisabled = MutableStateFlow(false) + val bluetoothDisabled: StateFlow = _bluetoothDisabled.asStateFlow() + + private val _syncError = MutableStateFlow(false) + val syncError: StateFlow = _syncError.asStateFlow() + + fun loadCache(context: Context) { + val cached = WearCardStore.load(context) + if (cached != null && _cards.value == null) { + _cards.value = cached + Log.d(TAG, "Loaded ${cached.size} cards from cache") + } + } + + fun setSyncing(value: Boolean) { + _syncing.value = value + if (value) { + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false + _syncError.value = false + } + } + + fun setPhoneNotReachable() { + _syncing.value = false + _phoneNotReachable.value = true + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false + } + + fun setPhoneOutdated() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = true + _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false + } + + fun setWatchOutdated() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = true + _permissionDenied.value = false + _bluetoothDisabled.value = false + } + + fun setPermissionDenied() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = true + _bluetoothDisabled.value = false + } + + fun setBluetoothDisabled() { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = true + } + + fun updateCards(context: Context, json: String) { + _syncing.value = false + _phoneNotReachable.value = false + _phoneOutdated.value = false + _watchOutdated.value = false + _permissionDenied.value = false + _bluetoothDisabled.value = false + try { + val incoming = WearCard.listFromJson(json) + _cards.value = incoming + _syncError.value = false + WearCardStore.save(context, incoming) + Log.d(TAG, "Cards updated: ${incoming.size} cards") + } catch (e: Exception) { + Log.e(TAG, "Failed to parse cards JSON", e) + _syncError.value = true + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt new file mode 100644 index 0000000000..1be564a45b --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearCardStore.kt @@ -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? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(KEY_CARDS_JSON, null) ?: return null + return runCatching { WearCard.listFromJson(json) }.getOrNull() + } + + fun save(context: Context, cards: List) { + val json = WearCard.listToJson(cards) + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit { + putString(KEY_CARDS_JSON, json) + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt new file mode 100644 index 0000000000..40a15fa2b5 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/WearProtocol.kt @@ -0,0 +1,9 @@ +package me.hackerchick.catima.wear + +import java.util.UUID + +object WearProtocol { + val BT_SERVICE_UUID: UUID = UUID.fromString("e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90") + const val PROTOCOL_VERSION = 1 + const val BT_CMD_CARDS_REQUEST_V1 = "V1/CARDS_REQUEST\n" +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt new file mode 100644 index 0000000000..a4696fee85 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardListScreen.kt @@ -0,0 +1,196 @@ +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.WearCard + +@Composable +fun CardListScreen( + cards: List?, + syncing: Boolean, + phoneNotReachable: Boolean, + phoneOutdated: Boolean, + watchOutdated: Boolean, + permissionDenied: Boolean, + bluetoothDisabled: Boolean, + syncError: Boolean, + onCardClick: (WearCard) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + when { + cards == null && syncError -> { + Text( + text = stringResource(R.string.sync_error), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && permissionDenied -> { + Text( + text = stringResource(R.string.bluetooth_permission_denied), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && bluetoothDisabled -> { + Text( + text = stringResource(R.string.bluetooth_disabled), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && phoneNotReachable -> { + Text( + text = stringResource(R.string.phone_not_connected), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && phoneOutdated -> { + Text( + text = stringResource(R.string.phone_outdated), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null && watchOutdated -> { + Text( + text = stringResource(R.string.watch_outdated), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + cards == null -> { + CircularProgressIndicator() + } + cards.isEmpty() && !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) { 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() + }, + ) + } + if (syncing) { + item { + Text( + text = stringResource(R.string.syncing), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (permissionDenied) { + item { + Text( + text = stringResource(R.string.bluetooth_permission_denied), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (bluetoothDisabled) { + item { + Text( + text = stringResource(R.string.bluetooth_disabled), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (phoneNotReachable) { + item { + Text( + text = stringResource(R.string.sync_failed), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (phoneOutdated) { + item { + Text( + text = stringResource(R.string.phone_outdated), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (syncError) { + item { + Text( + text = stringResource(R.string.sync_error), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } else if (watchOutdated) { + item { + Text( + text = stringResource(R.string.watch_outdated), + textAlign = TextAlign.Center, + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + } + } + } + } + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt new file mode 100644 index 0000000000..630c67b7ef --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/CardViewScreen.kt @@ -0,0 +1,172 @@ +package me.hackerchick.catima.wear.ui + +import android.graphics.Bitmap +import android.graphics.Color +import android.view.WindowManager +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.asImageBitmap +import androidx.activity.compose.LocalActivity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Text +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import me.hackerchick.catima.wear.R +import me.hackerchick.catima.wear.WearCard + +@Composable +fun CardViewScreen(card: WearCard?) { + Box( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.Black), + contentAlignment = Alignment.Center, + ) { + if (card == null) { + androidx.wear.compose.material.CircularProgressIndicator() + } else { + CardDetail(card = card) + } + } +} + +@Composable +private fun KeepScreenOnAtMaxBrightness() { + val activity = LocalActivity.current ?: return + DisposableEffect(Unit) { + val window = activity.window + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val originalBrightness = window.attributes.screenBrightness + val params = window.attributes + params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + window.attributes = params + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val restore = window.attributes + restore.screenBrightness = originalBrightness + window.attributes = restore + } + } +} + +@Composable +private fun CardDetail(card: WearCard) { + val barcodeValue = card.barcodeId ?: card.cardId + val barcodeType = card.barcodeType + + KeepScreenOnAtMaxBrightness() + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.White) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + 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 = androidx.compose.ui.graphics.Color.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 = androidx.compose.ui.graphics.Color.Black, + ) + } + } else { + Text( + text = stringResource(R.string.no_barcode), + textAlign = TextAlign.Center, + color = androidx.compose.ui.graphics.Color.Black, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = barcodeValue, + textAlign = TextAlign.Center, + maxLines = 2, + color = androidx.compose.ui.graphics.Color.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 + } +} diff --git a/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt b/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt new file mode 100644 index 0000000000..f22e99abd8 --- /dev/null +++ b/wear/src/main/java/me/hackerchick/catima/wear/ui/theme/CatimaWearTheme.kt @@ -0,0 +1,9 @@ +package me.hackerchick.catima.wear.ui.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +@Composable +fun CatimaWearTheme(content: @Composable () -> Unit) { + MaterialTheme(content = content) +} diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..1123a86f4f --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..f4b23782b3 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..d378acd7ac --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000000..3d7b397c0f --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + Catima + No cards found.\nOpen Catima on your phone to add cards. + Could not reach Catima on your phone.\nMake sure the app is installed and your watch is connected. + Loading cards… + No barcode available for this card. + Card ID + Syncing with phone… + Offline – showing cached cards + Catima on your phone is outdated.\nPlease update it. + This watch app is outdated.\nPlease update it. + Bluetooth permission denied.\nGrant it in Settings to sync cards. + Bluetooth is turned off.\nEnable it to sync cards from your phone. + Failed to read cards from your phone.\nTry syncing again. +