diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08b67b26c..6e7bbd8fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -215,7 +215,7 @@ composeCompiler { } dependencies { - implementation(fileTree("libs") { include("*.aar") }) + implementation(fileTree("libs") { include("*.aar", "*.jar") }) implementation(libs.jna) { artifact { type = "aar" } } implementation(platform(libs.kotlin.bom)) implementation(libs.core.ktx) diff --git a/app/libs/btleplug.aar b/app/libs/btleplug.aar new file mode 100644 index 000000000..eb983b31a Binary files /dev/null and b/app/libs/btleplug.aar differ diff --git a/app/libs/jni-utils.jar b/app/libs/jni-utils.jar new file mode 100644 index 000000000..c11668144 Binary files /dev/null and b/app/libs/jni-utils.jar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ff59496d8..97f12cb0e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,11 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# btleplug (droidplug) Android Bluetooth support +# These classes are loaded via JNI from Rust code +-keep class com.nonpolynomial.btleplug.** { *; } + +# jni-utils support library for btleplug +-keep class io.github.gedgygedgy.rust.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ebcf34f04..1a4273e8f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,24 @@ android:name="android.permission.FOREGROUND_SERVICE" tools:ignore="ForegroundServicePermission,ForegroundServicesPolicy" /> + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 27b3a7c17..eb6e8dd40 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -9,6 +9,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import to.bitkit.env.Env +import to.bitkit.services.BluetoothInit import javax.inject.Inject @HiltAndroidApp @@ -25,6 +26,8 @@ internal open class App : Application(), Configuration.Provider { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } Env.initAppStoragePath(filesDir.absolutePath) + // Initialize btleplug for Bluetooth support (required before any BLE usage) + BluetoothInit.ensureInitialized() } companion object { diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt new file mode 100644 index 000000000..7102ba38e --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -0,0 +1,467 @@ +package to.bitkit.repositories + +import android.content.Context +import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignedMessageResponse +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorTxInput +import com.synonym.bitkitcore.TrezorTxOutput +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.env.Env +import to.bitkit.services.TrezorDebugLog +import to.bitkit.services.TrezorService +import to.bitkit.services.TrezorTransport +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +data class TrezorState( + val isInitialized: Boolean = false, + val isScanning: Boolean = false, + val isConnecting: Boolean = false, + val isAutoReconnecting: Boolean = false, + val knownDevices: List = emptyList(), + val nearbyDevices: List = emptyList(), + val connectedDevice: TrezorFeatures? = null, + val connectedDeviceId: String? = null, + val lastAddress: TrezorAddressResponse? = null, + val lastPublicKey: TrezorPublicKeyResponse? = null, + val error: String? = null, +) + +@Suppress("TooManyFunctions") +@Singleton +class TrezorRepo @Inject constructor( + @ApplicationContext private val context: Context, + private val trezorService: TrezorService, + private val trezorTransport: TrezorTransport, +) { + companion object { + private const val TAG = "TrezorRepo" + private const val KEY_KNOWN_DEVICES = "known_devices" + } + + private val prefs by lazy { + context.getSharedPreferences("trezor_device", Context.MODE_PRIVATE) + } + + private val json = Json { ignoreUnknownKeys = true } + + private val _state = MutableStateFlow(TrezorState()) + val state = _state.asStateFlow() + + /** + * Flow indicating when a pairing code needs to be entered. + * UI should show a dialog when this emits true. + */ + val needsPairingCode = trezorTransport.needsPairingCode + + /** + * Submit the pairing code entered by the user. + */ + fun submitPairingCode(code: String) { + trezorTransport.submitPairingCode(code) + } + + /** + * Cancel pairing code entry. + */ + fun cancelPairingCode() { + trezorTransport.cancelPairingCode() + } + + suspend fun initialize(walletIndex: Int = 0): Result = runCatching { + val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json" + Logger.debug("Initializing Trezor with credential path: $credentialPath", context = TAG) + trezorService.initialize(credentialPath) + val known = loadKnownDevices() + _state.update { it.copy(isInitialized = true, knownDevices = known, error = null) } + }.onFailure { e -> + Logger.error("Trezor init failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun scan(): Result> = runCatching { + _state.update { it.copy(isScanning = true, error = null) } + val devices = trezorService.scan() + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val nearby = devices.filter { it.id !in knownIds } + _state.update { it.copy(isScanning = false, nearbyDevices = nearby) } + devices + }.onFailure { e -> + Logger.error("Trezor scan failed", e, context = TAG) + _state.update { it.copy(isScanning = false, error = e.message) } + } + + suspend fun listDevices(): Result> = runCatching { + val devices = trezorService.listDevices() + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val nearby = devices.filter { it.id !in knownIds } + _state.update { it.copy(nearbyDevices = nearby) } + devices + }.onFailure { e -> + Logger.error("Trezor listDevices failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun connect(deviceId: String): Result = runCatching { + _state.update { it.copy(isConnecting = true, error = null) } + TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId") + val features = connectWithThpRetry(deviceId) + TrezorDebugLog.log("CONNECT", "connect() succeeded: label=${features.label}, model=${features.model}") + val deviceInfo = _state.value.nearbyDevices.find { it.id == deviceId } + ?: _state.value.knownDevices.find { it.id == deviceId }?.let { known -> + TrezorDeviceInfo( + id = known.id, + transportType = when (known.transportType) { + "bluetooth" -> com.synonym.bitkitcore.TrezorTransportType.BLUETOOTH + else -> com.synonym.bitkitcore.TrezorTransportType.USB + }, + name = known.name, + path = known.path, + label = known.label, + model = known.model, + isBootloader = false, + ) + } + if (deviceInfo != null) { + addOrUpdateKnownDevice(deviceInfo, features) + } + _state.update { + it.copy( + isConnecting = false, + connectedDevice = features, + connectedDeviceId = deviceId, + nearbyDevices = it.nearbyDevices.filter { d -> d.id != deviceId }, + ) + } + features + }.onFailure { e -> + Logger.error("Trezor connect failed", e, context = TAG) + _state.update { it.copy(isConnecting = false, error = e.message) } + } + + suspend fun getAddress( + path: String = "m/84'/0'/0'/0/0", + showOnTrezor: Boolean = false, + scriptType: TrezorScriptType? = TrezorScriptType.SPEND_WITNESS, + ): Result = runCatching { + ensureConnected() + val response = trezorService.getAddress( + path = path, + coin = "Bitcoin", + showOnTrezor = showOnTrezor, + scriptType = scriptType, + ) + _state.update { it.copy(lastAddress = response, error = null) } + response + }.onFailure { e -> + Logger.error("Trezor getAddress failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun getPublicKey( + path: String = "m/84'/0'/0'", + showOnTrezor: Boolean = false, + ): Result = runCatching { + ensureConnected() + val response = trezorService.getPublicKey( + path = path, + coin = "Bitcoin", + showOnTrezor = showOnTrezor, + ) + _state.update { it.copy(lastPublicKey = response, error = null) } + response + }.onFailure { e -> + Logger.error("Trezor getPublicKey failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun disconnect(): Result = runCatching { + TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") + runCatching { trezorService.disconnect() } + _state.update { + it.copy(connectedDevice = null, connectedDeviceId = null, lastAddress = null, lastPublicKey = null) + } + TrezorDebugLog.log("DISCONNECT", "disconnect() complete (credentials NOT cleared)") + }.onFailure { e -> + TrezorDebugLog.log("DISCONNECT", "FAILED: ${e.message}") + Logger.error("Trezor disconnect failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun signMessage( + path: String = "m/84'/0'/0'/0/0", + message: String, + ): Result = runCatching { + ensureConnected() + val response = trezorService.signMessage( + path = path, + message = message, + coin = "Bitcoin", + ) + _state.update { it.copy(error = null) } + response + }.onFailure { e -> + Logger.error("Trezor signMessage failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun verifyMessage( + address: String, + signature: String, + message: String, + ): Result = runCatching { + ensureConnected() + val result = trezorService.verifyMessage( + address = address, + signature = signature, + message = message, + coin = "Bitcoin", + ) + _state.update { it.copy(error = null) } + result + }.onFailure { e -> + Logger.error("Trezor verifyMessage failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() + + suspend fun autoReconnect(walletIndex: Int = 0): Result { + val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } + if (knownDevices.isEmpty()) { + return Result.failure(IllegalStateException("No known devices")) + } + + _state.update { it.copy(isAutoReconnecting = true, error = null) } + return runCatching { + if (!_state.value.isInitialized) { + initialize(walletIndex).getOrThrow() + } + if (trezorService.isConnected()) { + _state.value.connectedDevice ?: error("Connected but no features") + } else { + val scannedDevices = scan().getOrThrow() + val match = knownDevices.firstNotNullOfOrNull { known -> + scannedDevices.find { it.id == known.id } + } ?: error("No known device found nearby") + connect(match.id).getOrThrow() + } + }.onSuccess { + _state.update { it.copy(isAutoReconnecting = false) } + }.onFailure { e -> + Logger.error("Auto-reconnect failed", e, context = TAG) + _state.update { it.copy(isAutoReconnecting = false, error = e.message) } + } + } + + suspend fun connectKnownDevice(deviceId: String): Result = runCatching { + _state.update { it.copy(isConnecting = true, error = null) } + TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") + TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") + TrezorDebugLog.log("RECONNECT", "isInitialized=${_state.value.isInitialized}") + if (!_state.value.isInitialized) { + TrezorDebugLog.log("RECONNECT", "Initializing...") + initialize().getOrThrow() + TrezorDebugLog.log("RECONNECT", "Initialized OK") + } + TrezorDebugLog.log("RECONNECT", "Scanning for devices...") + val scannedDevices = trezorService.scan() + TrezorDebugLog.log("RECONNECT", "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}") + val device = scannedDevices.find { it.id == deviceId } + ?: error("Device not found nearby — is it powered on?") + TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") + TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") + val features = connectWithThpRetry(device.id) + TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}") + addOrUpdateKnownDevice(device, features) + _state.update { + it.copy(isConnecting = false, connectedDevice = features, connectedDeviceId = deviceId) + } + TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice SUCCESS ===") + features + }.onFailure { e -> + TrezorDebugLog.log("RECONNECT", "FAILED: ${e.message}") + Logger.error("Connect known device failed", e, context = TAG) + _state.update { it.copy(isConnecting = false, error = e.message) } + } + + suspend fun forgetDevice(deviceId: String): Result = runCatching { + TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") + if (_state.value.connectedDeviceId == deviceId) { + runCatching { trezorService.disconnect() } + _state.update { it.copy(connectedDevice = null, connectedDeviceId = null) } + } + TrezorDebugLog.log("FORGET", "Clearing credentials...") + trezorTransport.clearDeviceCredential(deviceId) + runCatching { trezorService.clearCredentials(deviceId) } + val updated = _state.value.knownDevices.filter { it.id != deviceId } + saveKnownDevices(updated) + _state.update { it.copy(knownDevices = updated) } + TrezorDebugLog.log("FORGET", "Device forgotten successfully") + Logger.info("Forgot device: $deviceId", context = TAG) + }.onFailure { e -> + TrezorDebugLog.log("FORGET", "FAILED: ${e.message}") + Logger.error("Forget device failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + fun clearError() { + _state.update { it.copy(error = null) } + } + + fun observeExternalDisconnects(scope: CoroutineScope) { + trezorTransport.externalDisconnect.onEach { path -> + val currentId = _state.value.connectedDeviceId ?: return@onEach + val knownDevice = _state.value.knownDevices.find { it.path == path } + if (knownDevice?.id == currentId || path.contains(currentId)) { + Logger.warn("External disconnect detected for $currentId", context = TAG) + _state.update { + it.copy(connectedDevice = null, connectedDeviceId = null, error = "Device disconnected") + } + } + }.launchIn(scope) + } + + private fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { + val existing = _state.value.knownDevices + val known = KnownDevice( + id = deviceInfo.id, + name = deviceInfo.name, + path = deviceInfo.path, + transportType = when (deviceInfo.transportType) { + com.synonym.bitkitcore.TrezorTransportType.BLUETOOTH -> "bluetooth" + com.synonym.bitkitcore.TrezorTransportType.USB -> "usb" + }, + label = features.label ?: deviceInfo.label, + model = features.model ?: deviceInfo.model, + lastConnectedAt = System.currentTimeMillis(), + ) + val updated = existing.filter { it.id != known.id } + known + saveKnownDevices(updated) + _state.update { it.copy(knownDevices = updated) } + } + + private fun loadKnownDevices(): List = runCatching { + val str = prefs.getString(KEY_KNOWN_DEVICES, null) ?: return emptyList() + json.decodeFromString>(str) + }.onFailure { + Logger.error("Failed to load known devices", it, context = TAG) + }.getOrDefault(emptyList()) + + private fun saveKnownDevices(devices: List) { + runCatching { + prefs.edit().putString(KEY_KNOWN_DEVICES, json.encodeToString(devices)).commit() + }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } + } + + private suspend fun ensureConnected() { + if (trezorService.isConnected()) return + val deviceId = _state.value.connectedDeviceId + ?: _state.value.knownDevices.firstOrNull()?.id + ?: error("No device to reconnect") + if (!_state.value.isInitialized) { + initialize().getOrThrow() + } + val devices = trezorService.scan() + val device = devices.find { it.id == deviceId } + ?: error("Device not found during reconnect") + val features = connectWithThpRetry(device.id) + _state.update { it.copy(connectedDevice = features, connectedDeviceId = deviceId) } + } + + suspend fun signTx( + inputs: List, + outputs: List, + coin: String = "Bitcoin", + lockTime: UInt? = null, + version: UInt? = null, + ): Result = runCatching { + ensureConnected() + val response = trezorService.signTx( + inputs = inputs, + outputs = outputs, + coin = coin, + lockTime = lockTime, + version = version, + ) + _state.update { it.copy(error = null) } + response + }.onFailure { e -> + Logger.error("Trezor signTx failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun clearCredentials(deviceId: String): Result = runCatching { + trezorService.clearCredentials(deviceId) + _state.update { it.copy(error = null) } + }.onFailure { e -> + Logger.error("Trezor clearCredentials failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun connectWithThpRetry(deviceId: String): TrezorFeatures { + TrezorDebugLog.log("THPRetry", "First connect attempt for: $deviceId") + logCredentialFileState(deviceId, "BEFORE 1st attempt") + return try { + val result = trezorService.connect(deviceId) + logCredentialFileState(deviceId, "AFTER 1st attempt (success)") + TrezorDebugLog.log("THPRetry", "First attempt succeeded") + result + } catch (e: Exception) { + logCredentialFileState(deviceId, "AFTER 1st attempt (failed)") + TrezorDebugLog.log("THPRetry", "First attempt failed: ${e.message}") + if (!isRetryableError(e)) { + TrezorDebugLog.log("THPRetry", "Error not retryable, throwing") + throw e + } + TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") + Logger.warn("Connection failed for $deviceId, retrying: ${e.message}", context = TAG) + logCredentialFileState(deviceId, "BEFORE 2nd attempt") + val result = trezorService.connect(deviceId) + logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") + TrezorDebugLog.log("THPRetry", "Second attempt succeeded") + result + } + } + + private fun logCredentialFileState(deviceId: String, label: String) { + val sanitizedId = deviceId.replace(":", "_").replace("/", "_") + val credDir = java.io.File(context.filesDir, "trezor-thp-credentials") + val credFile = java.io.File(credDir, "$sanitizedId.json") + val exists = credFile.exists() + val size = if (exists) credFile.length() else 0 + TrezorDebugLog.log("CRED", "$label: file=$sanitizedId.json exists=$exists size=$size") + } + + private fun isRetryableError(e: Exception): Boolean { + val msg = e.message?.lowercase() ?: return false + return "thp" in msg || "session" in msg || "timeout" in msg || "disconnect" in msg + } +} + +@Serializable +data class KnownDevice( + val id: String, + val name: String?, + val path: String, + val transportType: String, + val label: String?, + val model: String?, + val lastConnectedAt: Long, +) diff --git a/app/src/main/java/to/bitkit/services/BluetoothInit.kt b/app/src/main/java/to/bitkit/services/BluetoothInit.kt new file mode 100644 index 000000000..63053cfd1 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/BluetoothInit.kt @@ -0,0 +1,71 @@ +package to.bitkit.services + +import to.bitkit.utils.Logger + +/** + * Helper object to initialize btleplug (droidplug) on Android. + * This must be called before using any Bluetooth functionality with Trezor devices. + * + * The initialization is performed via JNI to the Rust bitkitcore library, + * which in turn initializes btleplug's Android Bluetooth adapter. + */ +object BluetoothInit { + private const val TAG = "BluetoothInit" + private var initialized = false + private var initResult = false + + init { + // We must load the native library before calling JNI functions. + // UniFFI loads it lazily, but we need it now for Bluetooth init. + try { + System.loadLibrary("bitkitcore") + Logger.info("Loaded bitkitcore native library", context = TAG) + } catch (e: UnsatisfiedLinkError) { + Logger.error("Failed to load bitkitcore native library", e, context = TAG) + } + } + + /** + * Native JNI function to initialize btleplug on Android. + * This function name must match the Rust JNI function name pattern: + * Java_to_bitkit_services_BluetoothInit_nativeInit + */ + private external fun nativeInit(): Boolean + + /** + * Ensures Bluetooth is initialized for btleplug usage. + * This is idempotent - subsequent calls after the first will return + * the cached result without re-initializing. + * + * @return true if initialization succeeded, false otherwise + */ + @Synchronized + @Suppress("TooGenericExceptionCaught") + fun ensureInitialized(): Boolean { + if (!initialized) { + try { + initResult = nativeInit() + initialized = true + if (initResult) { + Logger.info("Bluetooth (btleplug) initialized successfully", context = TAG) + } else { + Logger.error("Bluetooth (btleplug) initialization returned false", context = TAG) + } + } catch (e: UnsatisfiedLinkError) { + Logger.error("Failed to initialize Bluetooth - native method not found", e, context = TAG) + initialized = true + initResult = false + } catch (e: Exception) { + Logger.error("Failed to initialize Bluetooth", e, context = TAG) + initialized = true + initResult = false + } + } + return initResult + } + + /** + * Returns whether Bluetooth has been successfully initialized. + */ + fun isInitialized(): Boolean = initialized && initResult +} diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt new file mode 100644 index 000000000..4d2033ed3 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -0,0 +1,34 @@ +package to.bitkit.services + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object TrezorDebugLog { + private const val MAX_LINES = 300 + private val _lines = MutableStateFlow>(emptyList()) + val lines: StateFlow> = _lines.asStateFlow() + + private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + fun log(tag: String, msg: String) { + val ts = fmt.format(Date()) + val line = "$ts [$tag] $msg" + synchronized(this) { + val current = _lines.value.toMutableList() + current.add(line) + if (current.size > MAX_LINES) { + _lines.value = current.takeLast(MAX_LINES) + } else { + _lines.value = current + } + } + } + + fun clear() { + _lines.value = emptyList() + } +} diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt new file mode 100644 index 000000000..87bc9ad18 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -0,0 +1,197 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorGetAddressParams +import com.synonym.bitkitcore.TrezorGetPublicKeyParams +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignMessageParams +import com.synonym.bitkitcore.TrezorSignTxParams +import com.synonym.bitkitcore.TrezorSignedMessageResponse +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorTxInput +import com.synonym.bitkitcore.TrezorTxOutput +import com.synonym.bitkitcore.TrezorVerifyMessageParams +import com.synonym.bitkitcore.trezorClearCredentials +import com.synonym.bitkitcore.trezorConnect +import com.synonym.bitkitcore.trezorDisconnect +import com.synonym.bitkitcore.trezorGetAddress +import com.synonym.bitkitcore.trezorGetConnectedDevice +import com.synonym.bitkitcore.trezorGetPublicKey +import com.synonym.bitkitcore.trezorInitialize +import com.synonym.bitkitcore.trezorIsConnected +import com.synonym.bitkitcore.trezorIsInitialized +import com.synonym.bitkitcore.trezorListDevices +import com.synonym.bitkitcore.trezorScan +import com.synonym.bitkitcore.trezorSetTransportCallback +import com.synonym.bitkitcore.trezorSignMessage +import com.synonym.bitkitcore.trezorSignTx +import com.synonym.bitkitcore.trezorVerifyMessage +import to.bitkit.async.ServiceQueue +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("TooManyFunctions") +@Singleton +class TrezorService @Inject constructor( + private val transport: TrezorTransport, +) { + @Volatile + private var callbackRegistered = false + + private fun ensureCallbackRegistered() { + if (!callbackRegistered) { + synchronized(this) { + if (!callbackRegistered) { + trezorSetTransportCallback(transport) + callbackRegistered = true + } + } + } + } + + suspend fun initialize(credentialPath: String? = null) { + ServiceQueue.CORE.background { + ensureCallbackRegistered() + trezorInitialize(credentialPath = credentialPath) + } + } + + suspend fun isInitialized(): Boolean { + return ServiceQueue.CORE.background { + trezorIsInitialized() + } + } + + suspend fun scan(): List { + return ServiceQueue.CORE.background { + trezorScan() + } + } + + suspend fun listDevices(): List { + return ServiceQueue.CORE.background { + trezorListDevices() + } + } + + suspend fun connect(deviceId: String): TrezorFeatures { + return ServiceQueue.CORE.background { + trezorConnect(deviceId = deviceId) + } + } + + suspend fun isConnected(): Boolean { + return ServiceQueue.CORE.background { + trezorIsConnected() + } + } + + suspend fun getAddress( + path: String, + coin: String? = "Bitcoin", + showOnTrezor: Boolean = false, + scriptType: TrezorScriptType? = null, + ): TrezorAddressResponse { + return ServiceQueue.CORE.background { + trezorGetAddress( + params = TrezorGetAddressParams( + path = path, + coin = coin, + showOnTrezor = showOnTrezor, + scriptType = scriptType, + ) + ) + } + } + + suspend fun getPublicKey( + path: String, + coin: String? = "Bitcoin", + showOnTrezor: Boolean = false, + ): TrezorPublicKeyResponse { + return ServiceQueue.CORE.background { + trezorGetPublicKey( + params = TrezorGetPublicKeyParams( + path = path, + coin = coin, + showOnTrezor = showOnTrezor, + ) + ) + } + } + + suspend fun disconnect() { + ServiceQueue.CORE.background { + trezorDisconnect() + } + } + + suspend fun getConnectedDevice(): TrezorDeviceInfo? { + return ServiceQueue.CORE.background { + trezorGetConnectedDevice() + } + } + + suspend fun signMessage( + path: String, + message: String, + coin: String? = "Bitcoin", + ): TrezorSignedMessageResponse { + return ServiceQueue.CORE.background { + trezorSignMessage( + params = TrezorSignMessageParams( + path = path, + message = message, + coin = coin, + ) + ) + } + } + + suspend fun verifyMessage( + address: String, + signature: String, + message: String, + coin: String? = "Bitcoin", + ): Boolean { + return ServiceQueue.CORE.background { + trezorVerifyMessage( + params = TrezorVerifyMessageParams( + address = address, + signature = signature, + message = message, + coin = coin, + ) + ) + } + } + + suspend fun signTx( + inputs: List, + outputs: List, + coin: String? = "Bitcoin", + lockTime: UInt? = null, + version: UInt? = null, + ): TrezorSignedTx { + return ServiceQueue.CORE.background { + trezorSignTx( + params = TrezorSignTxParams( + inputs = inputs, + outputs = outputs, + coin = coin, + lockTime = lockTime, + version = version, + ) + ) + } + } + + suspend fun clearCredentials(deviceId: String) { + ServiceQueue.CORE.background { + trezorClearCredentials(deviceId = deviceId) + } + } +} diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt new file mode 100644 index 000000000..7d9ee5ccf --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -0,0 +1,1183 @@ +package to.bitkit.services + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.ParcelUuid +import com.synonym.bitkitcore.NativeDeviceInfo +import com.synonym.bitkitcore.TrezorCallMessageResult +import com.synonym.bitkitcore.TrezorTransportCallback +import com.synonym.bitkitcore.TrezorTransportReadResult +import com.synonym.bitkitcore.TrezorTransportWriteResult +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import to.bitkit.utils.Logger +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Transport callback implementation for Trezor communication. + * + * This class implements the [TrezorTransportCallback] interface which is called by + * the Rust bitkit-core module for USB/Bluetooth I/O operations. + * + * USB communication uses 64-byte chunks, Bluetooth uses 244-byte chunks. + */ +@Suppress("LargeClass") +@Singleton +class TrezorTransport @Inject constructor( + @ApplicationContext private val context: Context, +) : TrezorTransportCallback { + + companion object { + private const val TAG = "TrezorTransport" + private const val ACTION_USB_PERMISSION = "to.bitkit.USB_PERMISSION" + + // USB constants + private const val USB_CHUNK_SIZE = 64 + private const val USB_PERMISSION_TIMEOUT_MS = 60_000L + private const val TREZOR_VENDOR_ID_1 = 0x1209 + private const val TREZOR_VENDOR_ID_2 = 0x534c + + // BLE constants + private const val BLE_CHUNK_SIZE = 244 + private val SERVICE_UUID = UUID.fromString("8c000001-a59b-4d58-a9ad-073df69fa1b1") + private val WRITE_CHAR_UUID = UUID.fromString("8c000002-a59b-4d58-a9ad-073df69fa1b1") + private val NOTIFY_CHAR_UUID = UUID.fromString("8c000003-a59b-4d58-a9ad-073df69fa1b1") + private val PUSH_CHAR_UUID = UUID.fromString("8c000004-a59b-4d58-a9ad-073df69fa1b1") + private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // Timeouts + private const val READ_TIMEOUT_MS = 5000 + private const val WRITE_TIMEOUT_MS = 5000 + private const val SCAN_DURATION_MS = 3000L + private const val CONNECTION_TIMEOUT_MS = 10000L + private const val BLE_READ_TIMEOUT_MS = 5000L + private const val DISCONNECT_TIMEOUT_MS = 3000L + private const val PAIRING_CODE_TIMEOUT_MS = 120000L // 2 minutes to enter code + + // BLE write retry settings + private const val BLE_WRITE_RETRY_COUNT = 3 + private const val BLE_WRITE_RETRY_DELAY_MS = 100L + private const val BLE_WRITE_INTER_DELAY_MS = 20L + private const val BLE_CONNECTION_STABILIZATION_MS = 1000L + + // BLE bonding constants + private const val MAX_BOND_POLL_ATTEMPTS = 60 + private const val BOND_POLL_INTERVAL_MS = 500L + } + + private val usbManager: UsbManager by lazy { + context.getSystemService(Context.USB_SERVICE) as UsbManager + } + + private val bluetoothManager: BluetoothManager by lazy { + context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + } + + private val credentialDir: File by lazy { + File(context.filesDir, "trezor-thp-credentials").also { it.mkdirs() } + } + + @Volatile + private var userInitiatedClose = false + + private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) + val externalDisconnect: SharedFlow = _externalDisconnect + + @Volatile + private var espMigrated = false + + @Suppress("TooGenericExceptionCaught") + private fun ensureEspMigration() { + if (espMigrated) return + synchronized(this) { + if (espMigrated) return + espMigrated = true + try { + val espPrefs = context.getSharedPreferences( + "trezor_thp_credentials", + Context.MODE_PRIVATE, + ) + val allEntries = espPrefs.all + if (allEntries.isEmpty()) return + var migrated = 0 + for ((key, value) in allEntries) { + if (!key.startsWith("thp_credential_") || value !is String) continue + val sanitizedId = key.removePrefix("thp_credential_") + val file = File(credentialDir, "$sanitizedId.json") + file.writeText(value) + migrated++ + } + if (migrated > 0) { + espPrefs.edit().clear().commit() + Logger.info("Migrated $migrated THP credentials from SharedPreferences to files", context = TAG) + } + } catch (e: Exception) { + Logger.warn("ESP migration failed (may be inaccessible)", e, context = TAG) + } + } + } + + private val bluetoothAdapter: BluetoothAdapter? by lazy { + bluetoothManager.adapter + } + + // USB connections + private val usbConnections = ConcurrentHashMap() + + // BLE connections + private val bleConnections = ConcurrentHashMap() + private val discoveredBleDevices = ConcurrentHashMap() + + private data class UsbOpenDevice( + val connection: UsbDeviceConnection, + val usbInterface: UsbInterface, + val readEndpoint: UsbEndpoint, + val writeEndpoint: UsbEndpoint, + ) + + private data class BleConnection( + val gatt: BluetoothGatt, + var readCharacteristic: BluetoothGattCharacteristic?, + var writeCharacteristic: BluetoothGattCharacteristic?, + val readQueue: LinkedBlockingQueue = LinkedBlockingQueue(), + @Volatile var isConnected: Boolean = false, + @Volatile var connectionLatch: CountDownLatch? = null, + @Volatile var writeLatch: CountDownLatch? = null, + @Volatile var disconnectLatch: CountDownLatch? = null, + @Volatile var writeStatus: Int = BluetoothGatt.GATT_SUCCESS, + ) + + // ==================== TrezorTransportCallback Implementation ==================== + + @Suppress("TooGenericExceptionCaught") + override fun enumerateDevices(): List { + val devices = mutableListOf() + + // Enumerate USB devices + try { + val usbDevices = usbManager.deviceList.values + .filter { isTrezorDevice(it) } + .map { device -> + NativeDeviceInfo( + path = device.deviceName, + transportType = "usb", + name = try { device.productName } catch (_: SecurityException) { null }, + vendorId = device.vendorId.toUShort(), + productId = device.productId.toUShort(), + ) + } + devices.addAll(usbDevices) + Logger.debug("USB enumerate found ${usbDevices.size} Trezor device(s)", context = TAG) + } catch (e: Exception) { + Logger.error("USB enumerate failed", e, context = TAG) + } + + // Enumerate Bluetooth devices + try { + val bleDevices = enumerateBleDevices() + devices.addAll(bleDevices) + Logger.debug("BLE enumerate found ${bleDevices.size} Trezor device(s)", context = TAG) + } catch (e: Exception) { + Logger.error("BLE enumerate failed", e, context = TAG) + } + + Logger.info("Total enumerate found ${devices.size} Trezor device(s)", context = TAG) + val summary = devices.map { "${it.path} (${it.transportType})" } + TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: $summary") + return devices + } + + override fun openDevice(path: String): TrezorTransportWriteResult { + TrezorDebugLog.log("OPEN", "openDevice: $path") + return if (isBleDevice(path)) { + openBleDevice(path) + } else { + openUsbDevice(path) + } + } + + override fun closeDevice(path: String): TrezorTransportWriteResult { + TrezorDebugLog.log("CLOSE", "closeDevice: $path") + return if (isBleDevice(path)) { + closeBleDevice(path) + } else { + closeUsbDevice(path) + } + } + + override fun readChunk(path: String): TrezorTransportReadResult { + return if (isBleDevice(path)) { + readBleChunk(path) + } else { + readUsbChunk(path) + } + } + + override fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult { + return if (isBleDevice(path)) { + writeBleChunk(path, data) + } else { + writeUsbChunk(path, data) + } + } + + override fun getChunkSize(path: String): UInt { + return if (isBleDevice(path)) { + BLE_CHUNK_SIZE.toUInt() + } else { + USB_CHUNK_SIZE.toUInt() + } + } + + override fun callMessage( + path: String, + messageType: UShort, + data: ByteArray + ): TrezorCallMessageResult? { + // For BLE/THP devices, the Rust side now handles THP protocol directly. + // This callback returns null to let Rust use its built-in THP implementation. + Logger.debug( + "callMessage called for $path, type=$messageType - returning null (Rust handles THP)", + context = TAG, + ) + return null + } + + override fun getPairingCode(): String { + // This is called by Rust during BLE THP pairing when the device + // displays a 6-digit code that must be entered. + // + // We use a blocking approach with a latch. The UI observes needsPairingCode + // and shows a dialog. When the user enters the code, submitPairingCode() + // is called which releases the latch. + TrezorDebugLog.log("PAIR", ">>> PAIRING CODE REQUESTED - Device requires re-pairing! <<<") + Logger.info(">>> PAIRING CODE REQUESTED <<<", context = TAG) + Logger.info("Look at your Trezor screen for a 6-digit code", context = TAG) + + val latch = CountDownLatch(1) + + synchronized(pairingCodeLock) { + submittedPairingCode = "" + pairingCodeRequest = PairingCodeRequest(isRequested = true, latch = latch) + _needsPairingCode.value = true + } + + try { + // Wait for user to enter the code (with timeout) + val received = latch.await(PAIRING_CODE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + + if (!received) { + Logger.warn("Pairing code entry timed out", context = TAG) + _needsPairingCode.value = false + return "" + } + + val code = submittedPairingCode + Logger.info("Pairing code received (len=${code.length})", context = TAG) + return code + } catch (e: InterruptedException) { + Logger.error("Pairing code wait interrupted", e, context = TAG) + _needsPairingCode.value = false + return "" + } + } + + /** + * Pairing code request state for UI observation. + * When getPairingCode() is called by Rust, we set this to true and wait. + */ + data class PairingCodeRequest( + val isRequested: Boolean = false, + val latch: CountDownLatch? = null, + ) + + @Volatile + private var pairingCodeRequest: PairingCodeRequest = PairingCodeRequest() + + @Volatile + private var submittedPairingCode: String = "" + + private val pairingCodeLock = Object() + + /** + * Flow to observe when a pairing code is needed. + * UI should show a dialog when this is true. + */ + private val _needsPairingCode = MutableStateFlow(false) + val needsPairingCode: kotlinx.coroutines.flow.StateFlow = _needsPairingCode + + /** + * Submit a pairing code from the UI. + * This unblocks the getPairingCode() call waiting on the Rust side. + */ + fun submitPairingCode(code: String) { + synchronized(pairingCodeLock) { + Logger.info("Pairing code submitted (len=${code.length})", context = TAG) + submittedPairingCode = code + _needsPairingCode.value = false + pairingCodeRequest.latch?.countDown() + } + } + + /** + * Cancel pairing code entry (submit empty string). + */ + fun cancelPairingCode() { + submitPairingCode("") + } + + @Suppress("TooGenericExceptionCaught") + override fun saveThpCredential(deviceId: String, credentialJson: String): Boolean { + ensureEspMigration() + return try { + val file = credentialFile(deviceId) + TrezorDebugLog.log("SAVE", "saveThpCredential called for: $deviceId") + TrezorDebugLog.log("SAVE", "File path: ${file.absolutePath}") + TrezorDebugLog.log("SAVE", "Credential length: ${credentialJson.length}") + + if (credentialJson.isEmpty()) { + val existed = file.exists() + file.delete() + TrezorDebugLog.log("SAVE", "CLEARED credential (file existed=$existed)") + Logger.info("Cleared THP credential for device: $deviceId (path=${file.absolutePath})", context = TAG) + return true + } + + file.writeText(credentialJson) + + // Immediately verify the file was written + val verifyExists = file.exists() + val verifySize = if (verifyExists) file.length() else 0 + TrezorDebugLog.log( + "SAVE", + "Wrote ${credentialJson.length} chars -> verify: exists=$verifyExists, size=$verifySize", + ) + if (!verifyExists || verifySize == 0L) { + TrezorDebugLog.log("SAVE", "WARNING: File verification FAILED after write!") + } + + Logger.info( + "Saving THP credential to: ${file.absolutePath} (${credentialJson.length} chars)", + context = TAG, + ) + true + } catch (e: Exception) { + TrezorDebugLog.log("SAVE", "EXCEPTION: ${e.message}") + Logger.error("Failed to save THP credential", e, context = TAG) + false + } + } + + override fun logDebug(tag: String, message: String) { + TrezorDebugLog.log("RUST:$tag", message) + } + + @Suppress("TooGenericExceptionCaught") + override fun loadThpCredential(deviceId: String): String? { + ensureEspMigration() + return try { + val file = credentialFile(deviceId) + val exists = file.exists() + val size = if (exists) file.length() else 0 + TrezorDebugLog.log("LOAD", "loadThpCredential for: $deviceId") + TrezorDebugLog.log("LOAD", "File: ${file.absolutePath}, exists=$exists, size=$size") + + // List all files in credential directory for debugging + val allFiles = credentialDir.listFiles()?.map { "${it.name} (${it.length()}b)" } ?: emptyList() + TrezorDebugLog.log("LOAD", "All credential files: $allFiles") + + Logger.info( + "Loading THP credential from: ${file.absolutePath}, exists=$exists, size=$size", + context = TAG, + ) + if (exists) { + val json = file.readText() + TrezorDebugLog.log("LOAD", "Loaded ${json.length} chars, blank=${json.isBlank()}") + if (json.isBlank()) { + TrezorDebugLog.log("LOAD", "WARNING: File exists but is blank! Returning null.") + null + } else { + Logger.info("Loaded THP credential for device: $deviceId (${json.length} chars)", context = TAG) + json + } + } else { + TrezorDebugLog.log("LOAD", "No credential file found -> returning null") + Logger.debug("No stored THP credential for device: $deviceId", context = TAG) + null + } + } catch (e: Exception) { + TrezorDebugLog.log("LOAD", "EXCEPTION: ${e.message}") + Logger.error("Failed to load THP credential", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + fun clearDeviceCredential(deviceId: String) { + try { + val file = credentialFile(deviceId) + TrezorDebugLog.log("CLEAR", "clearDeviceCredential for: $deviceId, exists=${file.exists()}") + file.delete() + Logger.info("Cleared device credential for: $deviceId", context = TAG) + } catch (e: Exception) { + TrezorDebugLog.log("CLEAR", "EXCEPTION: ${e.message}") + Logger.error("Failed to clear device credential", e, context = TAG) + } + } + + private fun credentialFile(deviceId: String): File { + val sanitizedId = deviceId.replace(":", "_").replace("/", "_") + return File(credentialDir, "$sanitizedId.json") + } + + // ==================== USB Methods ==================== + + /** + * Request USB permission for a device and block until the user responds. + * Returns true if permission was granted, false otherwise. + * + * This uses a BroadcastReceiver + CountDownLatch pattern because openDevice + * runs on a background thread (Rust FFI callback), not the main thread. + */ + @Suppress("TooGenericExceptionCaught") + private fun requestUsbPermission(device: UsbDevice): Boolean { + val latch = CountDownLatch(1) + var granted = false + + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action == ACTION_USB_PERMISSION) { + granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + latch.countDown() + } + } + } + + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + context.registerReceiver( + receiver, + IntentFilter(ACTION_USB_PERMISSION), + Context.RECEIVER_NOT_EXPORTED, + ) + + try { + Logger.info("Requesting USB permission for ${device.deviceName}", context = TAG) + usbManager.requestPermission(device, permissionIntent) + + // Block until user responds (up to 60 seconds) + val responded = latch.await(USB_PERMISSION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (!responded) { + Logger.warn("USB permission request timed out", context = TAG) + return false + } + + val status = if (granted) "granted" else "denied" + Logger.info("USB permission $status for ${device.deviceName}", context = TAG) + return granted + } finally { + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + } + } + + private data class UsbEndpoints(val read: UsbEndpoint, val write: UsbEndpoint) + + private fun findUsbEndpoints(usbInterface: UsbInterface): UsbEndpoints? { + var readEndpoint: UsbEndpoint? = null + var writeEndpoint: UsbEndpoint? = null + + for (i in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(i) + when { + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_IN -> { + readEndpoint = endpoint + } + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_OUT -> { + writeEndpoint = endpoint + } + } + } + + if (readEndpoint == null || writeEndpoint == null) return null + return UsbEndpoints(read = readEndpoint, write = writeEndpoint) + } + + @Suppress("TooGenericExceptionCaught", "ReturnCount") + private fun openUsbDevice(path: String): TrezorTransportWriteResult { + return try { + // Close existing connection if any + closeUsbDevice(path) + + val device = usbManager.deviceList[path] + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + + if (!usbManager.hasPermission(device)) { + Logger.info("USB permission not yet granted, requesting...", context = TAG) + if (!requestUsbPermission(device)) { + return TrezorTransportWriteResult( + success = false, + error = "USB permission denied for $path", + ) + } + } + + val connection = usbManager.openDevice(device) + ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path") + + val usbInterface = device.getInterface(0) + if (!connection.claimInterface(usbInterface, true)) { + connection.close() + return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") + } + + val endpoints = findUsbEndpoints(usbInterface) + if (endpoints == null) { + connection.releaseInterface(usbInterface) + connection.close() + return TrezorTransportWriteResult( + success = false, + error = "Could not find required endpoints", + ) + } + + usbConnections[path] = UsbOpenDevice( + connection, + usbInterface, + endpoints.read, + endpoints.write, + ) + Logger.info("USB device opened: $path", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } catch (e: Exception) { + Logger.error("USB open failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + } + } + + @Suppress("TooGenericExceptionCaught") + private fun closeUsbDevice(path: String): TrezorTransportWriteResult { + return try { + val openDevice = usbConnections.remove(path) + ?: return TrezorTransportWriteResult(success = true, error = "") + + openDevice.connection.releaseInterface(openDevice.usbInterface) + openDevice.connection.close() + Logger.info("USB device closed: $path", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } catch (e: Exception) { + Logger.error("USB close failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + } + } + + @Suppress("TooGenericExceptionCaught") + private fun readUsbChunk(path: String): TrezorTransportReadResult { + return try { + val openDevice = usbConnections[path] + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Device not open: $path", + ) + + val buffer = ByteArray(USB_CHUNK_SIZE) + val bytesRead = openDevice.connection.bulkTransfer( + openDevice.readEndpoint, + buffer, + buffer.size, + READ_TIMEOUT_MS, + ) + + if (bytesRead < 0) { + return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Read failed: $bytesRead", + ) + } + + val data = buffer.copyOf(bytesRead) + Logger.debug("USB read $bytesRead bytes from $path", context = TAG) + TrezorTransportReadResult(success = true, data = data, error = "") + } catch (e: Exception) { + Logger.error("USB read failed", e, context = TAG) + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") + } + } + + @Suppress("TooGenericExceptionCaught") + private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { + return try { + val openDevice = usbConnections[path] + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + + val bytesWritten = openDevice.connection.bulkTransfer( + openDevice.writeEndpoint, + data, + data.size, + WRITE_TIMEOUT_MS, + ) + + if (bytesWritten < 0) { + return TrezorTransportWriteResult(success = false, error = "Write failed: $bytesWritten") + } + + Logger.debug("USB wrote $bytesWritten bytes to $path", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } catch (e: Exception) { + Logger.error("USB write failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + } + } + + // ==================== Bluetooth Methods ==================== + + @SuppressLint("MissingPermission") + private fun enumerateBleDevices(): List { + if (bluetoothAdapter?.isEnabled != true) { + Logger.warn("Bluetooth is not enabled", context = TAG) + return emptyList() + } + + val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return emptyList() + + // Start fresh scan + discoveredBleDevices.clear() + + val scanFilter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(SERVICE_UUID)) + .build() + + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + scanner.startScan(listOf(scanFilter), scanSettings, bleScanCallback) + Logger.debug("BLE scan started", context = TAG) + + // Wait for scan results + Thread.sleep(SCAN_DURATION_MS) + + scanner.stopScan(bleScanCallback) + Logger.debug("BLE scan stopped", context = TAG) + + return discoveredBleDevices.values.map { device -> + NativeDeviceInfo( + path = "ble:${device.address}", + transportType = "bluetooth", + name = device.name ?: "Trezor", + vendorId = null, + productId = null, + ) + } + } + + private val bleScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val address = device.address + if (!discoveredBleDevices.containsKey(address)) { + discoveredBleDevices[address] = device + Logger.debug("BLE device found: $address (${device.name})", context = TAG) + } + } + + override fun onScanFailed(errorCode: Int) { + Logger.error("BLE scan failed: $errorCode", context = TAG) + } + } + + @SuppressLint("MissingPermission") + private fun waitForBonding( + device: BluetoothDevice, + address: String, + ): TrezorTransportWriteResult? { + if (device.bondState == BluetoothDevice.BOND_NONE) { + Logger.info("Device not bonded, initiating bonding: $address", context = TAG) + if (!device.createBond()) { + return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + } + var bondAttempts = 0 + while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { + Thread.sleep(BOND_POLL_INTERVAL_MS) + bondAttempts++ + if (device.bondState == BluetoothDevice.BOND_NONE) { + return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected") + } + } + if (device.bondState != BluetoothDevice.BOND_BONDED) { + return TrezorTransportWriteResult(success = false, error = "Bonding timeout") + } + Logger.info("Device bonded successfully: $address", context = TAG) + } else if (device.bondState == BluetoothDevice.BOND_BONDING) { + Logger.info("Device is currently bonding, waiting: $address", context = TAG) + var bondAttempts = 0 + while (device.bondState == BluetoothDevice.BOND_BONDING && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { + Thread.sleep(BOND_POLL_INTERVAL_MS) + bondAttempts++ + } + if (device.bondState != BluetoothDevice.BOND_BONDED) { + return TrezorTransportWriteResult(success = false, error = "Bonding failed") + } + } else { + Logger.info("Device already bonded: $address", context = TAG) + } + return null + } + + @SuppressLint("MissingPermission") + private fun openBleDevice(path: String): TrezorTransportWriteResult { + val address = path.removePrefix("ble:") + val device = discoveredBleDevices[address] + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + + // Close existing connection + closeBleDevice(path) + + // Check if device needs bonding + val bondError = waitForBonding(device, address) + if (bondError != null) return bondError + + val connectionLatch = CountDownLatch(1) + val gatt = device.connectGatt(context, false, bleGattCallback) + + val connection = BleConnection( + gatt = gatt, + readCharacteristic = null, + writeCharacteristic = null, + connectionLatch = connectionLatch + ) + + bleConnections[path] = connection + + if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + closeBleDevice(path) + return TrezorTransportWriteResult(success = false, error = "Connection timeout") + } + + val updatedConnection = bleConnections[path] + if (updatedConnection == null || !updatedConnection.isConnected) { + closeBleDevice(path) + return TrezorTransportWriteResult(success = false, error = "Failed to connect") + } + + // Request high-priority BLE connection for faster, more reliable handshake + gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + + // Drain any stale notifications from a previous connection attempt + val staleCount = updatedConnection.readQueue.size + if (staleCount > 0) { + updatedConnection.readQueue.clear() + TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") + } + + // Stabilization delay: device THP layer needs time after BLE reconnect + Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) + + Logger.info("BLE device opened: $path", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("MissingPermission") + private fun closeBleDevice(path: String): TrezorTransportWriteResult { + val connection = bleConnections.remove(path) + ?: return TrezorTransportWriteResult(success = true, error = "") + + userInitiatedClose = true + try { + val disconnectLatch = CountDownLatch(1) + bleConnections[path] = connection.copy(disconnectLatch = disconnectLatch) + + connection.gatt.disconnect() + + val disconnected = disconnectLatch.await(DISCONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (!disconnected) { + Logger.warn("BLE disconnect timeout, forcing close: $path", context = TAG) + } + + bleConnections.remove(path) + connection.gatt.close() + Thread.sleep(100) + } catch (e: Exception) { + Logger.error("BLE close failed", e, context = TAG) + } + + Logger.info("BLE device closed: $path", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + + @Suppress("TooGenericExceptionCaught") + private fun readBleChunk(path: String): TrezorTransportReadResult { + val connection = bleConnections[path] + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Device not open: $path" + ) + + return try { + val data = connection.readQueue.poll(BLE_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Read timeout" + ) + + Logger.debug("BLE read ${data.size} bytes from $path", context = TAG) + TrezorTransportReadResult(success = true, data = data, error = "") + } catch (e: Exception) { + Logger.error("BLE read failed", e, context = TAG) + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") + } + } + + @Suppress( + "TooGenericExceptionCaught", + "CyclomaticComplexMethod", + "LongMethod", + "NestedBlockDepth", + "ReturnCount", + "LoopWithTooManyJumpStatements", + ) + @SuppressLint("MissingPermission") + private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { + val connection = bleConnections[path] + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + + val writeChar = connection.writeCharacteristic + ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") + + if (!connection.isConnected) { + Logger.warn("BLE write attempted on disconnected device: $path", context = TAG) + return TrezorTransportWriteResult(success = false, error = "Device disconnected") + } + + return try { + // Retry logic for transient GATT busy states + var lastError = "Write initiation failed" + for (attempt in 1..BLE_WRITE_RETRY_COUNT) { + val writeLatch = CountDownLatch(1) + connection.writeLatch = writeLatch + connection.writeStatus = BluetoothGatt.GATT_SUCCESS + + @Suppress("DEPRECATION") + writeChar.value = data + @Suppress("DEPRECATION") + val success = connection.gatt.writeCharacteristic(writeChar) + + if (!success) { + // Get more diagnostic info + val connState = connection.isConnected + val charPropsHex = Integer.toHexString(writeChar.properties) + Logger.warn( + "BLE write initiation failed (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path, " + + "isConnected=$connState, charProps=0x$charPropsHex, dataLen=${data.size}", + context = TAG, + ) + if (attempt < BLE_WRITE_RETRY_COUNT) { + Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) + continue + } + return TrezorTransportWriteResult(success = false, error = lastError) + } + + if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { + lastError = "Write timeout" + Logger.warn("BLE write timeout (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", context = TAG) + if (attempt < BLE_WRITE_RETRY_COUNT) { + Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) + continue + } + return TrezorTransportWriteResult(success = false, error = lastError) + } + + if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { + lastError = "Write callback failed: ${connection.writeStatus}" + Logger.warn( + "BLE write callback failed with status ${connection.writeStatus} " + + "(attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", + context = TAG, + ) + if (attempt < BLE_WRITE_RETRY_COUNT) { + Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) + continue + } + return TrezorTransportWriteResult(success = false, error = lastError) + } + + // Success! + Logger.debug("BLE wrote ${data.size} bytes to $path (attempt $attempt)", context = TAG) + + // Small delay between writes to avoid overwhelming the GATT + Thread.sleep(BLE_WRITE_INTER_DELAY_MS) + + return TrezorTransportWriteResult(success = true, error = "") + } + + TrezorTransportWriteResult(success = false, error = lastError) + } catch (e: Exception) { + Logger.error("BLE write failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed") + } + } + + @SuppressLint("MissingPermission") + private val bleGattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] + + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + Logger.debug("BLE connected, requesting MTU: $path", context = TAG) + val mtuResult = gatt.requestMtu(256) + if (!mtuResult) { + Logger.warn("MTU request failed, proceeding with service discovery: $path", context = TAG) + gatt.discoverServices() + } + } + BluetoothProfile.STATE_DISCONNECTED -> { + Logger.debug("BLE disconnected: $path", context = TAG) + connection?.isConnected = false + connection?.connectionLatch?.countDown() + connection?.disconnectLatch?.countDown() + if (!userInitiatedClose) { + _externalDisconnect.tryEmit(path) + } + userInitiatedClose = false + } + } + } + + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + val path = "ble:${gatt.device.address}" + if (status == BluetoothGatt.GATT_SUCCESS) { + Logger.info("MTU changed to $mtu for $path", context = TAG) + } else { + Logger.warn("MTU change failed with status $status for $path", context = TAG) + } + gatt.discoverServices() + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + + if (status != BluetoothGatt.GATT_SUCCESS) { + Logger.error("Service discovery failed: $status", context = TAG) + connection.connectionLatch?.countDown() + return + } + + val service = gatt.getService(SERVICE_UUID) + if (service == null) { + Logger.error("Trezor service not found", context = TAG) + connection.connectionLatch?.countDown() + return + } + + val writeChar = service.getCharacteristic(WRITE_CHAR_UUID) + val notifyChar = service.getCharacteristic(NOTIFY_CHAR_UUID) + + if (writeChar == null || notifyChar == null) { + Logger.error("Required characteristics not found", context = TAG) + connection.connectionLatch?.countDown() + return + } + + // Use WRITE_TYPE_DEFAULT (with response) for more reliable writes + // Some Trezor devices don't handle NO_RESPONSE well + @Suppress("DEPRECATION") + writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + + gatt.setCharacteristicNotification(notifyChar, true) + + // Also subscribe to PUSH characteristic + val pushChar = service.getCharacteristic(PUSH_CHAR_UUID) + if (pushChar != null) { + gatt.setCharacteristicNotification(pushChar, true) + } + + connection.readCharacteristic = notifyChar + connection.writeCharacteristic = writeChar + connection.isConnected = false + + // Enable notifications via CCCD descriptor for TX characteristic + val descriptor = notifyChar.getDescriptor(CCCD_UUID) + if (descriptor != null) { + @Suppress("DEPRECATION") + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + val writeResult = gatt.writeDescriptor(descriptor) + if (!writeResult) { + Logger.warn("CCCD descriptor write failed to initiate: $path", context = TAG) + // Also enable CCCD for PUSH characteristic before signaling ready + enablePushCccd(gatt, pushChar, path) + connection.isConnected = true + connection.connectionLatch?.countDown() + } + } else { + Logger.warn("CCCD descriptor not found, proceeding: $path", context = TAG) + enablePushCccd(gatt, pushChar, path) + connection.isConnected = true + connection.connectionLatch?.countDown() + } + + Logger.info("BLE services discovered: $path", context = TAG) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + + // Only process notifications from the NOTIFY characteristic + if (characteristic.uuid != NOTIFY_CHAR_UUID) { + Logger.debug("Ignoring notification from non-TX char: ${characteristic.uuid}", context = TAG) + return + } + + @Suppress("DEPRECATION") + val data = characteristic.value + + if (data != null && data.isNotEmpty()) { + connection.readQueue.offer(data) + Logger.debug("BLE TX notification: ${data.size} bytes", context = TAG) + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + connection.writeStatus = status + if (status != BluetoothGatt.GATT_SUCCESS) { + Logger.warn("BLE write callback status: $status for $path", context = TAG) + } + connection.writeLatch?.countDown() + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int + ) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + + Thread.sleep(200) + + val charUuid = descriptor.characteristic.uuid + if (status == BluetoothGatt.GATT_SUCCESS) { + Logger.info( + "CCCD descriptor write complete for $charUuid: $path", + context = TAG, + ) + } else { + Logger.warn( + "CCCD descriptor write failed with status $status for $charUuid: $path", + context = TAG, + ) + } + + // If this was the TX characteristic CCCD, also enable PUSH CCCD + if (descriptor.characteristic.uuid == NOTIFY_CHAR_UUID) { + val pushChar = gatt.getService(SERVICE_UUID)?.getCharacteristic(PUSH_CHAR_UUID) + if (!enablePushCccd(gatt, pushChar, path)) { + // PUSH CCCD not available or failed, signal ready now + connection.isConnected = true + connection.connectionLatch?.countDown() + } + // If enablePushCccd returned true, onDescriptorWrite will fire again for PUSH + } else { + // This was the PUSH CCCD write (or other), signal connection ready + connection.isConnected = true + connection.connectionLatch?.countDown() + } + } + } + + @SuppressLint("MissingPermission") + private fun enablePushCccd( + gatt: BluetoothGatt, + pushChar: BluetoothGattCharacteristic?, + path: String, + ): Boolean { + if (pushChar == null) return false + val pushDescriptor = pushChar.getDescriptor(CCCD_UUID) ?: return false + @Suppress("DEPRECATION") + pushDescriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + val result = gatt.writeDescriptor(pushDescriptor) + if (!result) { + Logger.warn("PUSH CCCD descriptor write failed to initiate: $path", context = TAG) + } + return result + } + + // ==================== Utility Methods ==================== + + private fun isBleDevice(path: String): Boolean = path.startsWith("ble:") + + private fun isTrezorDevice(device: UsbDevice): Boolean { + return device.vendorId == TREZOR_VENDOR_ID_1 || device.vendorId == TREZOR_VENDOR_ID_2 + } + + fun hasUsbPermission(devicePath: String): Boolean { + val device = usbManager.deviceList[devicePath] ?: return false + return usbManager.hasPermission(device) + } + + fun getUsbDevice(devicePath: String): UsbDevice? { + return usbManager.deviceList[devicePath] + } + + fun closeAllConnections() { + usbConnections.keys.toList().forEach { path -> closeUsbDevice(path) } + bleConnections.keys.toList().forEach { path -> closeBleDevice(path) } + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 45ae8f1d5..3173e32db 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -65,6 +65,7 @@ import to.bitkit.ui.screens.profile.ProfileIntroScreen import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen import to.bitkit.ui.screens.recovery.RecoveryModeScreen import to.bitkit.ui.screens.scanner.QrScanningScreen +import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.scanner.SCAN_REQUEST_KEY import to.bitkit.ui.screens.settings.DevSettingsScreen import to.bitkit.ui.screens.settings.FeeSettingsScreen @@ -1060,6 +1061,9 @@ private fun NavGraphBuilder.advancedSettings(navController: NavHostController) { composableWithDefaultTransitions { NodeInfoScreen(navController) } + composableWithDefaultTransitions { + TrezorScreen(navController) + } } private fun NavGraphBuilder.aboutSettings(navController: NavHostController) { @@ -1751,6 +1755,10 @@ sealed interface Routes { @Serializable data object AddressViewer : Routes + @Serializable + data object Trezor : Routes + + @Serializable data object SweepNav : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt new file mode 100644 index 000000000..eb3b487de --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -0,0 +1,128 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.repositories.TrezorState +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun AddressSection( + trezorState: TrezorState, + uiState: TrezorUiState, + onGetAddress: (Boolean) -> Unit, + onIncrementIndex: () -> Unit, +) { + Column { + Text( + text = "Address Generation", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Path: ${uiState.derivationPath}", + color = Colors.White50, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = if (uiState.isGettingAddress) "Getting..." else "Get Address", + onClick = { onGetAddress(false) }, + enabled = !uiState.isGettingAddress, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = if (uiState.isGettingAddress) "Getting..." else "Show on Device", + onClick = { onGetAddress(true) }, + enabled = !uiState.isGettingAddress, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + + AnimatedVisibility(visible = trezorState.lastAddress != null) { + trezorState.lastAddress?.let { response -> + val onCopyAddress = copyToClipboard(text = response.address, label = "Address") + Column { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Address:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = response.address, + color = Colors.Brand, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy address", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyAddress) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + SecondaryButton( + text = "Next Index", + onClick = onIncrementIndex, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt new file mode 100644 index 000000000..8a0dd0e62 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt @@ -0,0 +1,60 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.TrezorFeatures +import to.bitkit.ui.theme.Colors + +@Composable +internal fun ConnectedDeviceInfo(features: TrezorFeatures) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Colors.White06) + .padding(12.dp) + ) { + InfoRow("Label", features.label ?: "-") + InfoRow("Model", features.model ?: "-") + InfoRow( + "Firmware", + "${features.majorVersion ?: 0}.${features.minorVersion ?: 0}.${features.patchVersion ?: 0}" + ) + InfoRow("PIN", if (features.pinProtection == true) "Enabled" else "Disabled") + InfoRow("Passphrase", if (features.passphraseProtection == true) "Enabled" else "Disabled") + } +} + +@Composable +internal fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Colors.White50, + fontSize = 12.sp, + ) + Text( + text = value, + color = Colors.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt new file mode 100644 index 000000000..a1f968e4c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -0,0 +1,147 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.R +import to.bitkit.repositories.KnownDevice +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors + +@Composable +internal fun DeviceCard( + device: TrezorDeviceInfo, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, Colors.White16, RoundedCornerShape(12.dp)) + .background(Colors.White06) + .clickableAlpha(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + when (device.transportType) { + TrezorTransportType.USB -> R.drawable.ic_git_branch + TrezorTransportType.BLUETOOTH -> R.drawable.ic_broadcast + } + ), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.label ?: device.name ?: device.model ?: "Trezor", + color = Colors.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = when (device.transportType) { + TrezorTransportType.USB -> "USB" + TrezorTransportType.BLUETOOTH -> "Bluetooth" + }, + color = Colors.White50, + fontSize = 12.sp, + ) + } + } +} + +@Composable +internal fun KnownDeviceCard( + device: KnownDevice, + isConnected: Boolean, + onClick: () -> Unit, + onForget: () -> Unit, +) { + val borderColor = if (isConnected) Colors.Green else Colors.White16 + val backgroundColor = if (isConnected) Colors.Green.copy(alpha = 0.08f) else Colors.White06 + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, borderColor, RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickableAlpha(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + if (device.transportType == "bluetooth") { + R.drawable.ic_broadcast + } else { + R.drawable.ic_git_branch + } + ), + contentDescription = null, + tint = if (isConnected) Colors.Green else Colors.White64, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.label ?: device.name ?: device.model ?: "Trezor", + color = Colors.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (device.transportType == "bluetooth") "Bluetooth" else "USB", + color = Colors.White50, + fontSize = 12.sp, + ) + Text( + text = if (isConnected) "Connected" else "Disconnected", + color = if (isConnected) Colors.Green else Colors.White32, + fontSize = 12.sp, + ) + } + } + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = "Forget device", + tint = Colors.White32, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onForget) + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt new file mode 100644 index 000000000..f79da3571 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -0,0 +1,97 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.ui.theme.Colors + +@Composable +internal fun PairingCodeDialog( + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onCancel, + containerColor = Colors.Gray5, + title = { + Text( + text = "Enter Pairing Code", + color = Colors.White, + fontWeight = FontWeight.SemiBold, + ) + }, + text = { + Column { + Text( + text = "Enter the 6-digit code shown on your Trezor device:", + color = Colors.White80, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = code, + onValueChange = { newValue -> + if (newValue.all { it.isDigit() } && newValue.length <= 6) { + code = newValue + } + }, + placeholder = { + Text("000000", color = Colors.White32) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + textStyle = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 24.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + letterSpacing = 8.sp, + ), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSubmit(code) }, + enabled = code.length == 6, + ) { + Text( + "Submit", + color = if (code.length == 6) Colors.Brand else Colors.White32, + ) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text("Cancel", color = Colors.White64) + } + }, + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt new file mode 100644 index 000000000..1cac1fd1e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -0,0 +1,159 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.repositories.TrezorState +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun PublicKeySection( + trezorState: TrezorState, + uiState: TrezorUiState, + onGetPublicKey: (Boolean) -> Unit, +) { + val accountPath = remember(uiState.derivationPath) { + uiState.derivationPath.split("/").take(4).joinToString("/") + } + + Column { + Text( + text = "Public Key (xpub)", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Account path: $accountPath", + color = Colors.White50, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = if (uiState.isGettingPublicKey) "Getting..." else "Get xpub", + onClick = { onGetPublicKey(false) }, + enabled = !uiState.isGettingPublicKey, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = if (uiState.isGettingPublicKey) "Getting..." else "Show on Device", + onClick = { onGetPublicKey(true) }, + enabled = !uiState.isGettingPublicKey, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + + AnimatedVisibility(visible = trezorState.lastPublicKey != null) { + trezorState.lastPublicKey?.let { response -> + val onCopyXpub = copyToClipboard(text = response.xpub, label = "xpub") + val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") + Column { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "xpub:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = response.xpub, + color = Colors.Brand, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy xpub", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyXpub) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Public Key:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = response.publicKey, + color = Colors.Brand, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy public key", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyPublicKey) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt new file mode 100644 index 000000000..fda405957 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -0,0 +1,135 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun SignMessageSection( + uiState: TrezorUiState, + onMessageChange: (String) -> Unit, + onSignMessage: () -> Unit, + onVerifyMessage: () -> Unit, +) { + Column { + Text( + text = "Sign Message", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.messageToSign, + onValueChange = onMessageChange, + label = { Text("Message", color = Colors.White50) }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + PrimaryButton( + text = if (uiState.isSigningMessage) "Signing..." else "Sign Message", + onClick = onSignMessage, + enabled = !uiState.isSigningMessage && + !uiState.isVerifyingMessage && + uiState.messageToSign.isNotBlank(), + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + SecondaryButton( + text = if (uiState.isVerifyingMessage) "Verifying..." else "Verify", + onClick = onVerifyMessage, + enabled = uiState.lastSignature != null && !uiState.isVerifyingMessage && !uiState.isSigningMessage, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + + AnimatedVisibility(visible = uiState.lastSignature != null) { + uiState.lastSignature?.let { sig -> + val onCopySignature = copyToClipboard(text = sig, label = "Signature") + Column { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Signature:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = sig, + color = Colors.Brand, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy signature", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopySignature) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt new file mode 100644 index 000000000..dd92d18d9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -0,0 +1,616 @@ +package to.bitkit.ui.screens.trezor + +import android.Manifest +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.R +import to.bitkit.repositories.KnownDevice +import to.bitkit.repositories.TrezorState +import to.bitkit.services.TrezorDebugLog +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState +import to.bitkit.viewmodels.TrezorViewModel + +private val bluetoothPermissions: List + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } + +@Composable +fun TrezorScreen(navController: NavController) { + TrezorScreenContent(onBack = { navController.popBackStack() }) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun TrezorScreenContent( + viewModel: TrezorViewModel = hiltViewModel(), + onBack: () -> Unit = {}, +) { + val trezorState by viewModel.trezorState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val needsPairingCode by viewModel.needsPairingCode.collectAsStateWithLifecycle() + + val permissionsState = rememberMultiplePermissionsState(bluetoothPermissions) + + LaunchedEffect(Unit) { + viewModel.initialize() + } + + val onScanWithPermissions: () -> Unit = { + if (permissionsState.allPermissionsGranted) { + viewModel.scan() + } else { + permissionsState.launchMultiplePermissionRequest() + } + } + + if (needsPairingCode) { + PairingCodeDialog( + onSubmit = viewModel::submitPairingCode, + onCancel = viewModel::cancelPairingCode, + ) + } + + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.settings__adv__trezor), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + TrezorContent( + trezorState = trezorState, + uiState = uiState, + onInitialize = viewModel::initialize, + onScan = onScanWithPermissions, + onConnectNearby = viewModel::connect, + onConnectKnown = viewModel::connectKnownDevice, + onForgetDevice = viewModel::forgetDevice, + onGetAddress = viewModel::getAddress, + onGetPublicKey = viewModel::getPublicKey, + onIncrementIndex = viewModel::incrementAddressIndex, + onDisconnect = viewModel::disconnect, + onSignMessage = viewModel::signMessage, + onVerifyMessage = viewModel::verifyMessage, + onMessageChange = viewModel::setMessageToSign, + onClearError = viewModel::clearError, + permissionsGranted = permissionsState.allPermissionsGranted, + ) + } +} + +@Composable +private fun TrezorContent( + trezorState: TrezorState, + uiState: TrezorUiState, + onInitialize: () -> Unit = {}, + onScan: () -> Unit = {}, + onConnectNearby: (String) -> Unit = {}, + onConnectKnown: (String) -> Unit = {}, + onForgetDevice: (KnownDevice) -> Unit = {}, + onGetAddress: (Boolean) -> Unit = {}, + onGetPublicKey: (Boolean) -> Unit = {}, + onIncrementIndex: () -> Unit = {}, + onDisconnect: () -> Unit = {}, + onSignMessage: () -> Unit = {}, + onVerifyMessage: () -> Unit = {}, + onMessageChange: (String) -> Unit = {}, + onClearError: () -> Unit = {}, + permissionsGranted: Boolean = true, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text13Up("TREZOR TEST", color = Colors.White64) + VerticalSpacer(16.dp) + + Card( + colors = CardDefaults.cardColors(containerColor = Colors.White08), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + StatusRow(trezorState) + + Spacer(modifier = Modifier.height(16.dp)) + + ActionButtonsRow( + trezorState = trezorState, + onInitialize = onInitialize, + onScan = onScan, + onDisconnect = onDisconnect, + permissionsGranted = permissionsGranted, + ) + + // Known Devices Section + AnimatedVisibility( + visible = trezorState.knownDevices.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "My Devices (${trezorState.knownDevices.size})", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + trezorState.knownDevices.forEach { device -> + val isConnected = trezorState.connectedDeviceId == device.id + KnownDeviceCard( + device = device, + isConnected = isConnected, + onClick = { + if (!isConnected && !trezorState.isConnecting) { + onConnectKnown(device.id) + } + }, + onForget = { onForgetDevice(device) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + // Nearby Devices Section + AnimatedVisibility( + visible = trezorState.nearbyDevices.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "New Devices (${trezorState.nearbyDevices.size})", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + trezorState.nearbyDevices.forEach { device -> + DeviceCard( + device = device, + onClick = { + if (!trezorState.isConnecting) { + onConnectNearby(device.id) + } + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + // Connected Device Info + AnimatedVisibility( + visible = trezorState.connectedDevice != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + trezorState.connectedDevice?.let { features -> + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Connected Device", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + ConnectedDeviceInfo(features) + + Spacer(modifier = Modifier.height(16.dp)) + + AddressSection( + trezorState = trezorState, + uiState = uiState, + onGetAddress = onGetAddress, + onIncrementIndex = onIncrementIndex, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PublicKeySection( + trezorState = trezorState, + uiState = uiState, + onGetPublicKey = onGetPublicKey, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SignMessageSection( + uiState = uiState, + onMessageChange = onMessageChange, + onSignMessage = onSignMessage, + onVerifyMessage = onVerifyMessage, + ) + } + } + } + + // Error Display + AnimatedVisibility(visible = trezorState.error != null) { + trezorState.error?.let { error -> + val onCopyError = copyToClipboard(text = error, label = "Trezor Error") + Column { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Red.copy(alpha = 0.1f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = error, + color = Colors.Red, + fontSize = 12.sp, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy error", + tint = Colors.Red, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyError) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = "Dismiss error", + tint = Colors.Red, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onClearError) + ) + } + } + } + } + + // Debug Log Window + DebugLogSection() + } + } + } +} + +@Composable +private fun DebugLogSection() { + var expanded by remember { mutableStateOf(false) } + val debugLines by TrezorDebugLog.lines.collectAsStateWithLifecycle() + + Column { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SecondaryButton( + text = if (expanded) "Hide (${debugLines.size})" else "Show Log (${debugLines.size})", + onClick = { expanded = !expanded }, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + if (expanded) { + val onCopyLogs = copyToClipboard( + text = debugLines.joinToString("\n"), + label = "Trezor Debug Log", + ) + SecondaryButton( + text = "Copy", + onClick = onCopyLogs, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + SecondaryButton( + text = "Clear", + onClick = { TrezorDebugLog.clear() }, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + val listState = rememberLazyListState() + + LaunchedEffect(debugLines.size) { + if (debugLines.isNotEmpty()) { + listState.animateScrollToItem(debugLines.size - 1) + } + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .padding(top = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Black.copy(alpha = 0.5f)) + .padding(8.dp), + ) { + items(debugLines) { line -> + Text( + text = line, + color = Colors.White80, + fontSize = 9.sp, + lineHeight = 12.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + } + } + } + } +} + +@Composable +private fun StatusRow(trezorState: TrezorState) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_settings_dev), + contentDescription = null, + tint = Colors.White80, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Trezor", + color = Colors.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + when { + trezorState.isAutoReconnecting -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Colors.Brand + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Reconnecting...", color = Colors.White64, fontSize = 12.sp) + } + trezorState.isScanning -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Colors.Brand + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scanning...", color = Colors.White64, fontSize = 12.sp) + } + trezorState.isConnecting -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Colors.Brand + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Connecting...", color = Colors.White64, fontSize = 12.sp) + } + trezorState.connectedDevice != null -> { + StatusBadge(text = "Connected", color = Colors.Green) + } + trezorState.isInitialized -> { + StatusBadge(text = "Ready", color = Colors.Brand) + } + else -> { + StatusBadge(text = "Not initialized", color = Colors.White32) + } + } + } + } +} + +@Composable +private fun StatusBadge(text: String, color: androidx.compose.ui.graphics.Color) { + Text( + text = text, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) +} + +@Composable +private fun ActionButtonsRow( + trezorState: TrezorState, + onInitialize: () -> Unit, + onScan: () -> Unit, + onDisconnect: () -> Unit, + permissionsGranted: Boolean = true, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (trezorState.isAutoReconnecting) return@Row + if (!trezorState.isInitialized) { + PrimaryButton( + text = "Initialize", + onClick = onInitialize, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } else if (trezorState.connectedDevice != null) { + SecondaryButton( + text = "Disconnect", + onClick = onDisconnect, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = if (permissionsGranted) "Scan" else "Grant Permissions", + onClick = onScan, + enabled = !trezorState.isScanning, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } else { + PrimaryButton( + text = if (permissionsGranted) "Scan" else "Grant Permissions", + onClick = onScan, + enabled = !trezorState.isScanning, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Preview +@Composable +private fun PreviewNotInitialized() { + AppThemeSurface { + TrezorContent( + trezorState = TrezorState(), + uiState = TrezorUiState(), + ) + } +} + +@Preview +@Composable +private fun PreviewInitialized() { + AppThemeSurface { + TrezorContent( + trezorState = TrezorState(isInitialized = true), + uiState = TrezorUiState(), + ) + } +} + +@Preview +@Composable +private fun PreviewWithDevices() { + val knownDevices = listOf( + KnownDevice( + id = "usb-1", + transportType = "usb", + name = "Trezor Safe 5", + path = "/dev/usb/001", + label = "My Savings", + model = "Safe 5", + lastConnectedAt = System.currentTimeMillis(), + ), + ) + val nearbyDevices = listOf( + TrezorDeviceInfo( + id = "ble-1", + transportType = TrezorTransportType.BLUETOOTH, + name = "Trezor Safe 7", + path = "AA:BB:CC:DD:EE:FF", + label = null, + model = "Safe 7", + isBootloader = false, + ), + ) + + AppThemeSurface { + TrezorContent( + trezorState = TrezorState( + isInitialized = true, + knownDevices = knownDevices, + nearbyDevices = nearbyDevices, + connectedDeviceId = "usb-1", + ), + uiState = TrezorUiState(), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 6e4467bb3..e346ae593 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -34,9 +35,11 @@ fun AdvancedSettingsScreen( navController: NavController, viewModel: AdvancedSettingsViewModel = hiltViewModel(), ) { + val isDevModeEnabled by viewModel.isDevModeEnabled.collectAsStateWithLifecycle() var showResetSuggestionsDialog by remember { mutableStateOf(false) } Content( + isDevModeEnabled = isDevModeEnabled, showResetSuggestionsDialog = showResetSuggestionsDialog, onBack = { navController.popBackStack() }, onCoinSelectionClick = { @@ -60,6 +63,9 @@ fun AdvancedSettingsScreen( onSweepFundsClick = { navController.navigate(Routes.SweepNav) }, + onTrezorClick = { + navController.navigate(Routes.Trezor) + }, onSuggestionsResetClick = { showResetSuggestionsDialog = true }, onResetSuggestionsDialogConfirm = { viewModel.resetSuggestions() @@ -72,6 +78,7 @@ fun AdvancedSettingsScreen( @Composable private fun Content( + isDevModeEnabled: Boolean = false, showResetSuggestionsDialog: Boolean, onBack: () -> Unit = {}, onCoinSelectionClick: () -> Unit = {}, @@ -81,6 +88,7 @@ private fun Content( onRgsServerClick: () -> Unit = {}, onAddressViewerClick: () -> Unit = {}, onSweepFundsClick: () -> Unit = {}, + onTrezorClick: () -> Unit = {}, onSuggestionsResetClick: () -> Unit = {}, onResetSuggestionsDialogConfirm: () -> Unit = {}, onResetSuggestionsDialogCancel: () -> Unit = {}, @@ -134,6 +142,17 @@ private fun Content( modifier = Modifier.testTag("RGSServer"), ) + // Hardware Wallet Section + if (isDevModeEnabled) { + SectionHeader(title = stringResource(R.string.settings__adv__section_hardware_wallet)) + + SettingsButtonRow( + title = stringResource(R.string.settings__adv__trezor), + onClick = onTrezorClick, + modifier = Modifier.testTag("Trezor"), + ) + } + // Other Section SectionHeader(title = stringResource(R.string.settings__adv__section_other)) diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt index 097b0f4cc..5d598f5f9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -3,6 +3,9 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import javax.inject.Inject @@ -12,6 +15,9 @@ class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, ) : ViewModel() { + val isDevModeEnabled = settingsStore.data.map { it.isDevModeEnabled } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + fun resetSuggestions() { viewModelScope.launch { settingsStore.update { it.copy(dismissedSuggestions = emptyList()) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt new file mode 100644 index 000000000..6da87db8f --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -0,0 +1,302 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorTxInput +import com.synonym.bitkitcore.TrezorTxOutput +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.di.BgDispatcher +import to.bitkit.models.Toast +import to.bitkit.repositories.KnownDevice +import to.bitkit.repositories.TrezorRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +data class TrezorUiState( + val addressIndex: Int = 0, + val derivationPath: String = "m/84'/0'/0'/0/0", + val messageToSign: String = "Hello, Trezor!", + val lastSignature: String? = null, + val lastSigningAddress: String? = null, + val isSigningMessage: Boolean = false, + val isGettingAddress: Boolean = false, + val isGettingPublicKey: Boolean = false, + val isVerifyingMessage: Boolean = false, +) + +@Suppress("TooManyFunctions") +@HiltViewModel +class TrezorViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val trezorRepo: TrezorRepo, +) : ViewModel() { + + init { + trezorRepo.observeExternalDisconnects(viewModelScope) + } + + val trezorState = trezorRepo.state + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), trezorRepo.state.value) + + /** + * Flow indicating when a pairing code is needed. + * UI should show a dialog when this is true. + */ + val needsPairingCode = trezorRepo.needsPairingCode + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + private val _uiState = MutableStateFlow(TrezorUiState()) + val uiState = _uiState.asStateFlow() + + fun hasKnownDevices(): Boolean = trezorRepo.hasKnownDevices() + + fun autoReconnect() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.autoReconnect() + .onSuccess { + val label = it.label ?: it.model ?: "Trezor" + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Reconnected to $label") + } + } + } + + fun initialize() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.initialize() + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Trezor initialized") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun scan() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.scan() + .onSuccess { devices -> + val count = devices.size + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = "Found $count device${if (count != 1) "s" else ""}" + ) + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun connect(deviceId: String) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.connect(deviceId) + .onSuccess { features -> + val label = features.label ?: features.model ?: "Trezor" + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Connected to $label") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun connectKnownDevice(deviceId: String) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.connectKnownDevice(deviceId) + .onSuccess { features -> + val label = features.label ?: features.model ?: "Trezor" + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Connected to $label") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun forgetDevice(device: KnownDevice) { + viewModelScope.launch(bgDispatcher) { + val name = device.label ?: device.name ?: "device" + trezorRepo.forgetDevice(device.id) + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Forgot $name") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun getAddress(showOnTrezor: Boolean = false) { + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(isGettingAddress = true) } + val path = _uiState.value.derivationPath + trezorRepo.getAddress( + path = path, + showOnTrezor = showOnTrezor, + scriptType = TrezorScriptType.SPEND_WITNESS, + ) + .onSuccess { + _uiState.update { it.copy(isGettingAddress = false) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Address generated") + } + .onFailure { + _uiState.update { it.copy(isGettingAddress = false) } + ToastEventBus.send(it) + } + } + } + + fun getPublicKey(showOnTrezor: Boolean = false) { + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(isGettingPublicKey = true) } + val path = _uiState.value.derivationPath + val accountPath = path.split("/").take(4).joinToString("/") + trezorRepo.getPublicKey( + path = accountPath, + showOnTrezor = showOnTrezor, + ) + .onSuccess { + _uiState.update { it.copy(isGettingPublicKey = false) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Public key retrieved") + } + .onFailure { + _uiState.update { it.copy(isGettingPublicKey = false) } + ToastEventBus.send(it) + } + } + } + + fun setDerivationPath(path: String) { + _uiState.update { it.copy(derivationPath = path) } + } + + fun incrementAddressIndex() { + _uiState.update { state -> + val newIndex = state.addressIndex + 1 + state.copy( + addressIndex = newIndex, + derivationPath = "m/84'/0'/0'/0/$newIndex" + ) + } + } + + fun disconnect() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.disconnect() + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Disconnected") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun setMessageToSign(message: String) { + _uiState.update { it.copy(messageToSign = message) } + } + + fun signMessage() { + viewModelScope.launch(bgDispatcher) { + val message = _uiState.value.messageToSign + if (message.isBlank()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Message cannot be empty") + return@launch + } + + _uiState.update { it.copy(isSigningMessage = true) } + val path = _uiState.value.derivationPath + trezorRepo.signMessage(path = path, message = message) + .onSuccess { response -> + _uiState.update { + it.copy( + lastSignature = response.signature, + lastSigningAddress = response.address, + isSigningMessage = false + ) + } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Message signed!") + } + .onFailure { e -> + _uiState.update { it.copy(isSigningMessage = false) } + ToastEventBus.send(e) + } + } + } + + fun verifyMessage() { + viewModelScope.launch(bgDispatcher) { + val signature = _uiState.value.lastSignature + val message = _uiState.value.messageToSign + val address = _uiState.value.lastSigningAddress + + if (signature == null || address == null) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Sign a message first") + return@launch + } + + _uiState.update { it.copy(isVerifyingMessage = true) } + trezorRepo.verifyMessage(address = address, signature = signature, message = message) + .onSuccess { isValid -> + _uiState.update { it.copy(isVerifyingMessage = false) } + val msg = if (isValid) "Signature is valid!" else "Signature is invalid" + val type = if (isValid) Toast.ToastType.SUCCESS else Toast.ToastType.ERROR + ToastEventBus.send(type = type, title = msg) + } + .onFailure { + _uiState.update { it.copy(isVerifyingMessage = false) } + ToastEventBus.send(it) + } + } + } + + fun clearError() { + trezorRepo.clearError() + } + + /** + * Submit the pairing code entered by the user. + */ + fun submitPairingCode(code: String) { + trezorRepo.submitPairingCode(code) + } + + /** + * Cancel pairing code entry. + */ + fun cancelPairingCode() { + trezorRepo.cancelPairingCode() + } + + /** + * Sign a Bitcoin transaction. + */ + fun signTx( + inputs: List, + outputs: List, + coin: String = "Bitcoin", + lockTime: UInt? = null, + version: UInt? = null, + ) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.signTx(inputs, outputs, coin, lockTime, version) + .onSuccess { signedTx -> + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = "Transaction signed (${signedTx.signatures.size} inputs)" + ) + } + .onFailure { ToastEventBus.send(it) } + } + } + + /** + * Clear stored pairing credentials for a device. + */ + fun clearCredentials(deviceId: String) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.clearCredentials(deviceId) + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Credentials cleared") + } + .onFailure { ToastEventBus.send(it) } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d080f226..1488fc371 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -560,7 +560,9 @@ Networks Other Payments + Hardware Wallet Reset Suggestions + Trezor Advanced Connection Receipts Connections diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 000000000..ace761801 --- /dev/null +++ b/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 029621f5c..d3f08a7a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.38" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.39" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }