From bd97cf4ebdb59153a956f6a2bb0bf83bf22eab74 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 18 Feb 2026 09:20:37 -0500 Subject: [PATCH 1/3] feat: trezor hardware support - Implement trezor hardware support via USB and Bluetooth --- app/build.gradle.kts | 2 +- app/libs/btleplug.aar | Bin 0 -> 25184 bytes app/libs/jni-utils.jar | Bin 0 -> 13116 bytes app/proguard-rules.pro | 9 +- app/src/main/AndroidManifest.xml | 26 + app/src/main/java/to/bitkit/App.kt | 3 + .../java/to/bitkit/repositories/TrezorRepo.kt | 465 +++++++ .../java/to/bitkit/services/BluetoothInit.kt | 70 ++ .../java/to/bitkit/services/TrezorDebugLog.kt | 34 + .../java/to/bitkit/services/TrezorService.kt | 196 +++ .../to/bitkit/services/TrezorTransport.kt | 1109 +++++++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 8 + .../ui/screens/trezor/AddressSection.kt | 128 ++ .../screens/trezor/ConnectedDeviceSection.kt | 60 + .../ui/screens/trezor/DeviceListSection.kt | 147 +++ .../ui/screens/trezor/PairingCodeDialog.kt | 97 ++ .../ui/screens/trezor/PublicKeySection.kt | 159 +++ .../ui/screens/trezor/SignMessageSection.kt | 133 ++ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 616 +++++++++ .../ui/settings/AdvancedSettingsScreen.kt | 19 + .../ui/settings/AdvancedSettingsViewModel.kt | 6 + .../to/bitkit/viewmodels/TrezorViewModel.kt | 301 +++++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/usb_device_filter.xml | 9 + 24 files changed, 3597 insertions(+), 2 deletions(-) create mode 100644 app/libs/btleplug.aar create mode 100644 app/libs/jni-utils.jar create mode 100644 app/src/main/java/to/bitkit/repositories/TrezorRepo.kt create mode 100644 app/src/main/java/to/bitkit/services/BluetoothInit.kt create mode 100644 app/src/main/java/to/bitkit/services/TrezorDebugLog.kt create mode 100644 app/src/main/java/to/bitkit/services/TrezorService.kt create mode 100644 app/src/main/java/to/bitkit/services/TrezorTransport.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt create mode 100644 app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt create mode 100644 app/src/main/res/xml/usb_device_filter.xml 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 0000000000000000000000000000000000000000..eb983b31a79f2e2adf96c642ffac8644fed986a8 GIT binary patch literal 25184 zcmV)9K*hgMO9KQ7000OG0000%0000000IC20000000jU508%b=cyt2*P)h>@3IG5I z2mk;8K>&000vJ002R5WO8q5WKCgiX=Y_}bS`*pY^{+^Ps1<_K=1t( zQSM9lxS*=-G|D89pgpW`MWOM!sEHHRDT04b87+wO=A-@mY}wiR!&&wY0~wDwzZ4go z0k&Qn>~cQF{pyCVizIUj%^oPS2}ep5>cJbG^Fxdwm6F;6I7MR4i`F}-LN5m(Ip=u7 zrUpQC7S~3&D;ZXGL;kvyp&SqU8)b5NVeth} zO9KQ7000OG0000%0I4h9sWD#w0EBJ;01E&B0Ap-nb8}^LE^1+NoV#UEB+;_A3k*)< z?(Xg`gS)%COXKd)xVyW%yThP^GlTo!?gInd*+=5uz2kl-zOyS<{mAN7D=IRxpEt8a zS?&uY*ne!Ku%r-R|DWw2hs~TEn3eyJGtmBPhNF`s(8=E0(aFKa#QuMnkN^KW-_*_C z0%-4U^*@|Q{y(2+;%M&TWMlq6EI{*rw}6cU@c+Gj*#B6?a)_=>2^Y8ong~@$5}BAA_1#hSL+K@)4-wn&^Lv`lyxc!|_(FpDD`2CF%Ap zI-#7TCob+UT1*kLoZcpSz?h8NYx4b?1uipbHB805R%-OuqcfVfFIM__M6Eg!k4>xTg6KGas*p2 zFxu$LoE{ePdmxqaa{jrXUADEP^q;YHNpifvVr>LE{CE3WdEtj4r=Xr0DIh_KMn@ zjexvcG!TENCCe+lM}xnREP{A(c=ZF;RRA8qkr~*EMmp%-QTqQ`S+O6zu^Hf z6MK786EnO2bVOe2hU1b53g}ZoG#5u~3n;{$m_GWI2enTGgS3c5Ts;Os*JZ#%vu^sE z-S40iGt%Jb&RuXXn$g;GE^ZQ_g^h@45v<#p z6m3e0Ox_0D<@PQgt$pOnlyDRUf9%*9S@z~KdmvBI2bZyUJOl$ex@UoJ!3N~`H;`G_ zvDl@=r-yUNlUcl(tmCuB(uc}xcN*^&_s_zMJ)0*nyC%`5UG}JU@`ER&cb~rx&5Y9V zK{M%e#?k{E$INi$i6F@od=F431B?X0w~fO4(QTO6r%=SqijP7}#t_JFxr|=O)_(Pu zN{_37XRzmo`4~du^Thz>s%?20Pq9MFip!uuj~@>MVY*+VRs~~a;Br(IQj!pDHIA7gr#N;20Lk+UP zC0wH5cby-sP2RHh`$BnB@YmozW50gHC7Atuaa`Eg{NHEOeZh(-^;?z`j=ui*<4jLoT+{}bTgaK*nNEv-i8IlSF*_`~t z)TFQGqMiT%CxhqWn9{1S`%o??O}DxBz8V`frMU(3dfTd>| zQRX?V*kbxL3T8(25)#VBZ^HFn&`s_H4s>>%h8QwgAbuzhhU^wj#@pa%6D)V?Xrflh zCIo+YkDArcKK1q8wyg);@3bf7t&${sSF7lp=#bqxT?GzPSxK@_RjUbKAn5hCrcY-} zRp#wfVbEfLoCtPm#D2fu>DYK^P^PBEf*ll=_JuxT!_FI{$zPzRRq3&aVQuT&*e7G^ za0UxQ%;@3|!UjR!Pp?#?QV9j$5%Y*pm0ia3yVy8f6h#2EFJ&SwD)=tl$EX~hOlZ^= z7x}2GchW>M{I#Z#6;PUJ^O7ogIdta`eNjabFCT^5u^3PEm{v*X)@6;ghKulOsxxE1 zqIsD^S?F#KM+TjMuY|igm}Z^QyIJ=woH;9$d6afVzN|6`QS7J!)QQ~+r-r)jF^S!dIC zeLT9#fp20*3j&UcxizKiS;+gTBEWg3I}eK=jSopv;)b5HGCYv$EnFMFE01vQx&m+> z>hUgw{HOC7A>J~C5|1>BTe-Dw-@d*L_mjOhh>Z^$LiD=@^lr;Pe-OCx4BaDul>BJT zK#YauI$YQGDOd{8%4I(`e{$Gd9(v;2v_H&XYkLObtihZW3Czz#h9=m@MbNe|9tzeH^3v|cI-F)wvdA~r|LaI+liraCw(p2@D%q*3pzs?cY-KTalIzo~K7T`; zgM9lXHvn95ZNjW^^~__^o|qf2p-sD61184MlT9{m38NwJMcOA<{lICVGO<(G&oBI@ zMMxH&f#$~mAL&K>7{6dQOeKw5H_Qm8O_k9K;ecn}`1-Ry{CrlxWDc<*x@Ja=+^T*3 zz!SCFEYAz#M|I~SVFCnhKsNjEOYmB{(Y=}izev5{ej5;Lgqy6 z-7ValoZPG>yv!_sZZ=Mi|D|||eTpM27~u&UrA0-`G#Du8W_lC?gW@$vB$;H;mCZd> zD|oyfseFF~u+%7_iGm_TS8XU!SoZclR*(I(`mc!_oWN%1kyO5N2K@|5z#G~V^fgvz z<1Fh4XCwQir35z@J0D%hg(90t`n7E-F3Cp zMDJERv`+2umsvfd|8qDl+^?-3|60v|Ln8OT2#2DRn!A~`n6-(EiP_)k!^YLk#_V4r zQ>WadI3fb@{}~1;S`^7FB0o6DC?AAV2`{%?R{8eJg-kNTHPsNs4+|YlOgJb(RKFNW zJ`!B+$v2?6fHE+^zZP77mt+K1CE6>Nkpdzk+wOFg)82^iXK5`%FK65(QJIUyw7P6H zKE0EJl|V~_CG$E*{wf^B&%=owSaIr#X^ahsmVx!}-V3L;x>cGsPP)3@i^91Fv>B4d z$tmpj^-S__h4A>PBe`wOax1DkPNS<@t!SNdpTAzyMA~=jSB~O+o)3&F<_eZuhEW%9 zehP_C+^hxh2Tultx;!D>Vokv6R8Epet|Gek>fo9=Y3~r1Q~gU#XB_u^qBy1*P`Omp>}|RQe_+q&zJ5+X9x`gMI;hfnYpi6#wul&p0$%F#Gp5w+p;>=6E|* z&GhZ;ZywL8UDX!V6f1FQKik>!*BTu2+iV1y(50Csk_ONskrL8rxuBRnZ99yG&5mQA z%3t?z<~rFR4nlHu+}v%}`8C!HFEDG<464�~o&rNF+ck?Hg&$K{e}L={g)O_gsDQ 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 Z|Kp&QWZ@D1%N5*5#r@a;m(hRC{s-?2{%ZgL literal 0 HcmV?d00001 diff --git a/app/libs/jni-utils.jar b/app/libs/jni-utils.jar new file mode 100644 index 0000000000000000000000000000000000000000..c11668144514d4254729efe25bed8814a65dd7d3 GIT binary patch literal 13116 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 Uj6N6`5%BL6xWBU81!-Xa2iZa}lmGw# literal 0 HcmV?d00001 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..6753bca90 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -0,0 +1,465 @@ +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, +) + +@Singleton +class TrezorRepo @Inject constructor( + @ApplicationContext private val context: Context, + private val trezorService: TrezorService, + private val trezorTransport: TrezorTransport, +) { + 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) } + } + + 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 + } + + companion object { + private const val TAG = "TrezorRepo" + private const val KEY_KNOWN_DEVICES = "known_devices" + } +} + +@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..d3f61f514 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/BluetoothInit.kt @@ -0,0 +1,70 @@ +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 + 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..32a2e77bd --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -0,0 +1,196 @@ +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 + +@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..c186f6d47 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -0,0 +1,1109 @@ +package to.bitkit.services + +import android.annotation.SuppressLint +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.app.PendingIntent +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. + */ +@Singleton +class TrezorTransport @Inject constructor( + @ApplicationContext private val context: Context, +) : TrezorTransportCallback { + + 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 + + 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.message}", 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 ==================== + + 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) + TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: ${devices.map { "${it.path} (${it.transportType})" }}") + 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("") + } + + 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) + } + + 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 + } + } + + 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. + */ + 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 + } + + Logger.info("USB permission ${if (granted) "granted" else "denied"} for ${device.deviceName}", context = TAG) + return granted + } finally { + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + } + } + + 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") + } + + 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) { + connection.releaseInterface(usbInterface) + connection.close() + return TrezorTransportWriteResult(success = false, error = "Could not find required endpoints") + } + + usbConnections[path] = UsbOpenDevice(connection, usbInterface, readEndpoint, writeEndpoint) + 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") + } + } + + 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") + } + } + + 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") + } + } + + 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 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 + if (device.bondState == BluetoothDevice.BOND_NONE) { + Logger.info("Device not bonded, initiating bonding: $address", context = TAG) + val bondResult = device.createBond() + if (!bondResult) { + return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + } + // Wait for bonding to complete + var bondAttempts = 0 + while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < 60) { + Thread.sleep(500) + 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 < 60) { + Thread.sleep(500) + bondAttempts++ + } + if (device.bondState != BluetoothDevice.BOND_BONDED) { + return TrezorTransportWriteResult(success = false, error = "Bonding failed") + } + } else { + Logger.info("Device already bonded: $address", context = TAG) + } + + 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 = "") + } + + @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 = "") + } + + 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") + } + } + + @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 charProps = writeChar.properties + Logger.warn( + "BLE write initiation failed (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path, " + + "isConnected=$connState, charProps=0x${charProps.toString(16)}, 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) + + if (status == BluetoothGatt.GATT_SUCCESS) { + Logger.info("CCCD descriptor write complete for ${descriptor.characteristic.uuid}: $path", context = TAG) + } else { + Logger.warn("CCCD descriptor write failed with status $status for ${descriptor.characteristic.uuid}: $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) } + } + + 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 + } +} 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..b9a4bdade --- /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.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 androidx.compose.foundation.layout.Arrangement +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..edcd758eb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -0,0 +1,133 @@ +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..5117b1553 --- /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.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.services.TrezorDebugLog +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..8f1da7e2f --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -0,0 +1,301 @@ +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, +) + +@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 @@ + + + + + + + + + From 74de0cee9395aa12efb806c6d76ed1b3219afdaf Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 18 Feb 2026 10:24:00 -0500 Subject: [PATCH 2/3] fix: detekt warning --- .../java/to/bitkit/repositories/TrezorRepo.kt | 12 +- .../java/to/bitkit/services/BluetoothInit.kt | 1 + .../java/to/bitkit/services/TrezorService.kt | 1 + .../to/bitkit/services/TrezorTransport.kt | 242 ++++++++++++------ .../ui/screens/trezor/PublicKeySection.kt | 2 +- .../ui/screens/trezor/SignMessageSection.kt | 4 +- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 2 +- .../to/bitkit/viewmodels/TrezorViewModel.kt | 1 + 8 files changed, 173 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 6753bca90..7102ba38e 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -42,12 +42,18 @@ data class TrezorState( 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) } @@ -408,6 +414,7 @@ class TrezorRepo @Inject constructor( _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") @@ -446,11 +453,6 @@ class TrezorRepo @Inject constructor( val msg = e.message?.lowercase() ?: return false return "thp" in msg || "session" in msg || "timeout" in msg || "disconnect" in msg } - - companion object { - private const val TAG = "TrezorRepo" - private const val KEY_KNOWN_DEVICES = "known_devices" - } } @Serializable diff --git a/app/src/main/java/to/bitkit/services/BluetoothInit.kt b/app/src/main/java/to/bitkit/services/BluetoothInit.kt index d3f61f514..63053cfd1 100644 --- a/app/src/main/java/to/bitkit/services/BluetoothInit.kt +++ b/app/src/main/java/to/bitkit/services/BluetoothInit.kt @@ -40,6 +40,7 @@ object BluetoothInit { * @return true if initialization succeeded, false otherwise */ @Synchronized + @Suppress("TooGenericExceptionCaught") fun ensureInitialized(): Boolean { if (!initialized) { try { diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 32a2e77bd..87bc9ad18 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -33,6 +33,7 @@ import to.bitkit.async.ServiceQueue import javax.inject.Inject import javax.inject.Singleton +@Suppress("TooManyFunctions") @Singleton class TrezorService @Inject constructor( private val transport: TrezorTransport, diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index c186f6d47..7d9ee5ccf 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -1,6 +1,7 @@ package to.bitkit.services import android.annotation.SuppressLint +import android.app.PendingIntent import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt @@ -13,7 +14,6 @@ import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings -import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -52,11 +52,50 @@ import javax.inject.Singleton * * 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 } @@ -78,6 +117,7 @@ class TrezorTransport @Inject constructor( @Volatile private var espMigrated = false + @Suppress("TooGenericExceptionCaught") private fun ensureEspMigration() { if (espMigrated) return synchronized(this) { @@ -103,7 +143,7 @@ class TrezorTransport @Inject constructor( Logger.info("Migrated $migrated THP credentials from SharedPreferences to files", context = TAG) } } catch (e: Exception) { - Logger.warn("ESP migration failed (may be inaccessible): ${e.message}", context = TAG) + Logger.warn("ESP migration failed (may be inaccessible)", e, context = TAG) } } } @@ -140,6 +180,7 @@ class TrezorTransport @Inject constructor( // ==================== TrezorTransportCallback Implementation ==================== + @Suppress("TooGenericExceptionCaught") override fun enumerateDevices(): List { val devices = mutableListOf() @@ -172,7 +213,8 @@ class TrezorTransport @Inject constructor( } Logger.info("Total enumerate found ${devices.size} Trezor device(s)", context = TAG) - TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: ${devices.map { "${it.path} (${it.transportType})" }}") + val summary = devices.map { "${it.path} (${it.transportType})" } + TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: $summary") return devices } @@ -225,7 +267,10 @@ class TrezorTransport @Inject constructor( ): 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) + Logger.debug( + "callMessage called for $path, type=$messageType - returning null (Rust handles THP)", + context = TAG, + ) return null } @@ -312,6 +357,7 @@ class TrezorTransport @Inject constructor( submitPairingCode("") } + @Suppress("TooGenericExceptionCaught") override fun saveThpCredential(deviceId: String, credentialJson: String): Boolean { ensureEspMigration() return try { @@ -333,12 +379,18 @@ class TrezorTransport @Inject constructor( // 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") + 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) + Logger.info( + "Saving THP credential to: ${file.absolutePath} (${credentialJson.length} chars)", + context = TAG, + ) true } catch (e: Exception) { TrezorDebugLog.log("SAVE", "EXCEPTION: ${e.message}") @@ -351,6 +403,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("RUST:$tag", message) } + @Suppress("TooGenericExceptionCaught") override fun loadThpCredential(deviceId: String): String? { ensureEspMigration() return try { @@ -390,6 +443,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun clearDeviceCredential(deviceId: String) { try { val file = credentialFile(deviceId) @@ -416,6 +470,7 @@ class TrezorTransport @Inject constructor( * 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 @@ -453,13 +508,39 @@ class TrezorTransport @Inject constructor( return false } - Logger.info("USB permission ${if (granted) "granted" else "denied"} for ${device.deviceName}", context = TAG) + 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 @@ -471,7 +552,10 @@ class TrezorTransport @Inject constructor( 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") + return TrezorTransportWriteResult( + success = false, + error = "USB permission denied for $path", + ) } } @@ -484,30 +568,22 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") } - 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) { + val endpoints = findUsbEndpoints(usbInterface) + if (endpoints == null) { connection.releaseInterface(usbInterface) connection.close() - return TrezorTransportWriteResult(success = false, error = "Could not find required endpoints") + return TrezorTransportWriteResult( + success = false, + error = "Could not find required endpoints", + ) } - usbConnections[path] = UsbOpenDevice(connection, usbInterface, readEndpoint, writeEndpoint) + usbConnections[path] = UsbOpenDevice( + connection, + usbInterface, + endpoints.read, + endpoints.write, + ) Logger.info("USB device opened: $path", context = TAG) TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { @@ -516,6 +592,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun closeUsbDevice(path: String): TrezorTransportWriteResult { return try { val openDevice = usbConnections.remove(path) @@ -531,6 +608,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun readUsbChunk(path: String): TrezorTransportReadResult { return try { val openDevice = usbConnections[path] @@ -565,6 +643,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] @@ -647,25 +726,18 @@ class TrezorTransport @Inject constructor( } @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 + private fun waitForBonding( + device: BluetoothDevice, + address: String, + ): TrezorTransportWriteResult? { if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: $address", context = TAG) - val bondResult = device.createBond() - if (!bondResult) { + if (!device.createBond()) { return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") } - // Wait for bonding to complete var bondAttempts = 0 - while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < 60) { - Thread.sleep(500) + 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") @@ -678,8 +750,8 @@ class TrezorTransport @Inject constructor( } 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 < 60) { - Thread.sleep(500) + while (device.bondState == BluetoothDevice.BOND_BONDING && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { + Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ } if (device.bondState != BluetoothDevice.BOND_BONDED) { @@ -688,6 +760,21 @@ class TrezorTransport @Inject constructor( } 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) @@ -729,6 +816,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = true, error = "") } + @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections.remove(path) @@ -757,6 +845,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = true, error = "") } + @Suppress("TooGenericExceptionCaught") private fun readBleChunk(path: String): TrezorTransportReadResult { val connection = bleConnections[path] ?: return TrezorTransportReadResult( @@ -781,6 +870,14 @@ class TrezorTransport @Inject constructor( } } + @Suppress( + "TooGenericExceptionCaught", + "CyclomaticComplexMethod", + "LongMethod", + "NestedBlockDepth", + "ReturnCount", + "LoopWithTooManyJumpStatements", + ) @SuppressLint("MissingPermission") private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { val connection = bleConnections[path] @@ -810,11 +907,11 @@ class TrezorTransport @Inject constructor( if (!success) { // Get more diagnostic info val connState = connection.isConnected - val charProps = writeChar.properties + val charPropsHex = Integer.toHexString(writeChar.properties) Logger.warn( "BLE write initiation failed (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path, " + - "isConnected=$connState, charProps=0x${charProps.toString(16)}, dataLen=${data.size}", - context = TAG + "isConnected=$connState, charProps=0x$charPropsHex, dataLen=${data.size}", + context = TAG, ) if (attempt < BLE_WRITE_RETRY_COUNT) { Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) @@ -835,7 +932,11 @@ class TrezorTransport @Inject constructor( 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) + 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 @@ -1010,10 +1111,17 @@ class TrezorTransport @Inject constructor( Thread.sleep(200) + val charUuid = descriptor.characteristic.uuid if (status == BluetoothGatt.GATT_SUCCESS) { - Logger.info("CCCD descriptor write complete for ${descriptor.characteristic.uuid}: $path", context = TAG) + Logger.info( + "CCCD descriptor write complete for $charUuid: $path", + context = TAG, + ) } else { - Logger.warn("CCCD descriptor write failed with status $status for ${descriptor.characteristic.uuid}: $path", context = TAG) + 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 @@ -1072,38 +1180,4 @@ class TrezorTransport @Inject constructor( usbConnections.keys.toList().forEach { path -> closeUsbDevice(path) } bleConnections.keys.toList().forEach { path -> closeBleDevice(path) } } - - 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 - } } 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 index b9a4bdade..1cac1fd1e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -2,6 +2,7 @@ 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 @@ -23,7 +24,6 @@ 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 androidx.compose.foundation.layout.Arrangement import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize 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 index edcd758eb..fda405957 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -75,7 +75,9 @@ internal fun SignMessageSection( PrimaryButton( text = if (uiState.isSigningMessage) "Signing..." else "Sign Message", onClick = onSignMessage, - enabled = !uiState.isSigningMessage && !uiState.isVerifyingMessage && uiState.messageToSign.isNotBlank(), + enabled = !uiState.isSigningMessage && + !uiState.isVerifyingMessage && + uiState.messageToSign.isNotBlank(), size = ButtonSize.Small, modifier = Modifier.weight(1f) ) 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 index 5117b1553..dd92d18d9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -54,6 +54,7 @@ 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 @@ -64,7 +65,6 @@ 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.services.TrezorDebugLog import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard import to.bitkit.viewmodels.TrezorUiState diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt index 8f1da7e2f..6da87db8f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -32,6 +32,7 @@ data class TrezorUiState( val isVerifyingMessage: Boolean = false, ) +@Suppress("TooManyFunctions") @HiltViewModel class TrezorViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, From 391242f3e812be3d8dae2c7d78c74d1429479737 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 18 Feb 2026 10:37:48 -0500 Subject: [PATCH 3/3] fix: bump bitkit-core to 0.1.39 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }