mirror of
https://github.com/CatimaLoyalty/Android.git
synced 2026-07-31 10:18:34 -04:00
Add WearOS companion app (#3185)
Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
This commit is contained in:
committed by
GitHub
parent
92776c9e8d
commit
3e970bae92
46
.github/workflows/wear.yml
vendored
Normal file
46
.github/workflows/wear.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -113,6 +113,8 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
// AndroidX
|
||||
implementation(libs.androidx.appcompat.appcompat)
|
||||
implementation(libs.androidx.constraintlayout.constraintlayout)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package protect.card_locker
|
||||
|
||||
object NotificationInfo {
|
||||
object WearBluetooth {
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val CHANNEL_ID = "catima_wear_bt"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -21,3 +21,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
rootProject.name = "Catima"
|
||||
include(":app")
|
||||
include(":wear")
|
||||
include(":shared")
|
||||
|
||||
29
shared/build.gradle.kts
Normal file
29
shared/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
55
wear/build.gradle.kts
Normal 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
1
wear/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
-keep class me.hackerchick.catima.wear.** { *; }
|
||||
4
wear/src/debug/res/values/strings.xml
Normal file
4
wear/src/debug/res/values/strings.xml
Normal 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>
|
||||
31
wear/src/main/AndroidManifest.xml
Normal file
31
wear/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt
Normal file
111
wear/src/main/java/me/hackerchick/catima/wear/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
15
wear/src/main/java/me/hackerchick/catima/wear/SyncStatus.kt
Normal file
15
wear/src/main/java/me/hackerchick/catima/wear/SyncStatus.kt
Normal 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),
|
||||
}
|
||||
44
wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt
Normal file
44
wear/src/main/java/me/hackerchick/catima/wear/WearCard.kt
Normal 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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
9
wear/src/main/res/drawable/ic_launcher_background.xml
Normal file
9
wear/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
59
wear/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
59
wear/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
16
wear/src/main/res/values/strings.xml
Normal file
16
wear/src/main/res/values/strings.xml
Normal 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>
|
||||
Reference in New Issue
Block a user