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" }