Skip to content

Commit 280c4e7

Browse files
committed
feat: add trezor bridge transport
1 parent e951e8a commit 280c4e7

4 files changed

Lines changed: 487 additions & 5 deletions

File tree

app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ val bcp47Locales = listOf(
4848
)
4949
val e2eBackendEnv = System.getenv("E2E_BACKEND") ?: "local"
5050
val e2eHomegateUrlEnv = System.getenv("E2E_HOMEGATE_URL") ?: "http://127.0.0.1:6288"
51+
val trezorBridgeEnv = System.getenv("TREZOR_BRIDGE")?.toBoolean()?.toString() ?: "false"
52+
val trezorBridgeUrlEnv = System.getenv("TREZOR_BRIDGE_URL") ?: "http://10.0.2.2:21325"
5153

5254
android {
5355
namespace = "to.bitkit"
@@ -65,6 +67,8 @@ android {
6567
buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false")
6668
buildConfigField("String", "E2E_BACKEND", "\"$e2eBackendEnv\"")
6769
buildConfigField("String", "E2E_HOMEGATE_URL", "\"$e2eHomegateUrlEnv\"")
70+
buildConfigField("boolean", "TREZOR_BRIDGE", trezorBridgeEnv)
71+
buildConfigField("String", "TREZOR_BRIDGE_URL", "\"$trezorBridgeUrlEnv\"")
6872
buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true")
6973
buildConfigField("String", "LOCALES", "\"${bcp47Locales.joinToString(",")}\"")
7074
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package to.bitkit.services
2+
3+
import com.synonym.bitkitcore.NativeDeviceInfo
4+
import com.synonym.bitkitcore.TrezorCallMessageResult
5+
import com.synonym.bitkitcore.TrezorTransportReadResult
6+
import com.synonym.bitkitcore.TrezorTransportWriteResult
7+
import kotlinx.serialization.Serializable
8+
import kotlinx.serialization.json.Json
9+
import to.bitkit.BuildConfig
10+
import to.bitkit.ext.fromHex
11+
import to.bitkit.ext.toHex
12+
import to.bitkit.utils.Logger
13+
import java.net.HttpURLConnection
14+
import java.net.URL
15+
import java.net.URLEncoder
16+
import java.nio.ByteBuffer
17+
import java.nio.charset.StandardCharsets
18+
import java.util.concurrent.ConcurrentHashMap
19+
import javax.inject.Inject
20+
import javax.inject.Singleton
21+
22+
@Suppress("MagicNumber")
23+
@Singleton
24+
class TrezorBridgeTransport(
25+
private val baseUrl: String,
26+
private val enabled: Boolean,
27+
) {
28+
@Inject
29+
constructor() : this(
30+
baseUrl = BuildConfig.TREZOR_BRIDGE_URL,
31+
enabled = BuildConfig.TREZOR_BRIDGE,
32+
)
33+
34+
companion object {
35+
private const val TAG = "TrezorBridgeTransport"
36+
private const val BRIDGE_PATH_PREFIX = "bridge:"
37+
private const val BRIDGE_DEVICE_NAME = "Trezor Bridge Emulator"
38+
private const val TREZOR_VENDOR_ID = 0x1209
39+
private const val TREZOR_PRODUCT_ID = 0x53c1
40+
private const val HEADER_SIZE = 6
41+
private const val CONNECT_TIMEOUT_MS = 5_000
42+
private const val READ_TIMEOUT_MS = 30_000
43+
}
44+
45+
private val json = Json { ignoreUnknownKeys = true }
46+
private val bridgeUrl = baseUrl.trimEnd('/')
47+
private val openSessions = ConcurrentHashMap<String, String>()
48+
private val enumeratedSessions = ConcurrentHashMap<String, String>()
49+
50+
fun isBridgeDevice(path: String): Boolean {
51+
return enabled && path.startsWith(BRIDGE_PATH_PREFIX)
52+
}
53+
54+
fun enumerateDevices(): List<NativeDeviceInfo> {
55+
if (!enabled) return emptyList()
56+
57+
return runCatching {
58+
val response = post("/enumerate")
59+
json.decodeFromString<List<BridgeDevice>>(response).map {
60+
val bridgePath = toBridgePath(it.path)
61+
if (it.session == null) {
62+
enumeratedSessions.remove(bridgePath)
63+
} else {
64+
enumeratedSessions[bridgePath] = it.session
65+
}
66+
NativeDeviceInfo(
67+
path = bridgePath,
68+
transportType = "usb",
69+
name = BRIDGE_DEVICE_NAME,
70+
vendorId = TREZOR_VENDOR_ID.toUShort(),
71+
productId = TREZOR_PRODUCT_ID.toUShort(),
72+
)
73+
}
74+
}.onSuccess {
75+
Logger.info("Enumerated '${it.size}' Trezor Bridge device(s)", context = TAG)
76+
}.getOrElse {
77+
Logger.warn("Failed to enumerate Trezor Bridge devices", it, context = TAG)
78+
emptyList()
79+
}
80+
}
81+
82+
fun openDevice(path: String): TrezorTransportWriteResult {
83+
val rawPath = rawBridgePath(path)
84+
val previousSession = openSessions.remove(path) ?: enumeratedSessions[path] ?: "null"
85+
86+
return runCatching {
87+
val response = post("/acquire/${encode(rawPath)}/${encode(previousSession)}")
88+
val session = json.decodeFromString<BridgeSession>(response).session
89+
openSessions[path] = session
90+
Logger.info("Opened Trezor Bridge device '$path'", context = TAG)
91+
TrezorTransportWriteResult(success = true, error = "")
92+
}.getOrElse {
93+
Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG)
94+
TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed")
95+
}
96+
}
97+
98+
fun closeDevice(path: String): TrezorTransportWriteResult {
99+
val session = openSessions.remove(path)
100+
?: return TrezorTransportWriteResult(success = true, error = "")
101+
102+
return runCatching {
103+
post("/release/${encode(session)}")
104+
Logger.info("Closed Trezor Bridge device '$path'", context = TAG)
105+
TrezorTransportWriteResult(success = true, error = "")
106+
}.getOrElse {
107+
Logger.warn("Failed to close Trezor Bridge device '$path'", it, context = TAG)
108+
TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed")
109+
}
110+
}
111+
112+
fun readChunk(path: String): TrezorTransportReadResult {
113+
return TrezorTransportReadResult(
114+
success = false,
115+
data = byteArrayOf(),
116+
error = "Trezor Bridge uses callMessage for '$path'",
117+
)
118+
}
119+
120+
fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult {
121+
return TrezorTransportWriteResult(
122+
success = false,
123+
error = "Trezor Bridge uses callMessage for '$path' and ignored '${data.size}' bytes",
124+
)
125+
}
126+
127+
fun callMessage(
128+
path: String,
129+
messageType: UShort,
130+
data: ByteArray,
131+
): TrezorCallMessageResult {
132+
val session = openSessions[path]
133+
?: return TrezorCallMessageResult(
134+
success = false,
135+
messageType = 0u.toUShort(),
136+
data = byteArrayOf(),
137+
error = "Trezor Bridge device not open: $path",
138+
)
139+
140+
return runCatching {
141+
val request = encodeFrame(messageType, data)
142+
val response = post("/call/${encode(session)}", request)
143+
decodeFrame(response)
144+
}.getOrElse {
145+
Logger.warn("Failed to call Trezor Bridge message for '$path'", it, context = TAG)
146+
TrezorCallMessageResult(
147+
success = false,
148+
messageType = 0u.toUShort(),
149+
data = byteArrayOf(),
150+
error = it.message ?: "Bridge call failed",
151+
)
152+
}
153+
}
154+
155+
private fun encodeFrame(messageType: UShort, data: ByteArray): String {
156+
return ByteBuffer.allocate(HEADER_SIZE + data.size)
157+
.putShort(messageType.toShort())
158+
.putInt(data.size)
159+
.put(data)
160+
.array()
161+
.toHex()
162+
}
163+
164+
private fun decodeFrame(hex: String): TrezorCallMessageResult {
165+
val bytes = hex.trim().fromHex()
166+
require(bytes.size >= HEADER_SIZE) { "Bridge response is shorter than '$HEADER_SIZE' bytes" }
167+
168+
val messageType = (((bytes[0].toInt() and 0xff) shl 8) or (bytes[1].toInt() and 0xff)).toUShort()
169+
val length = ((bytes[2].toInt() and 0xff) shl 24) or
170+
((bytes[3].toInt() and 0xff) shl 16) or
171+
((bytes[4].toInt() and 0xff) shl 8) or
172+
(bytes[5].toInt() and 0xff)
173+
require(bytes.size >= HEADER_SIZE + length) {
174+
"Bridge response payload length '$length' exceeds '${bytes.size - HEADER_SIZE}' bytes"
175+
}
176+
177+
return TrezorCallMessageResult(
178+
success = true,
179+
messageType = messageType,
180+
data = bytes.copyOfRange(HEADER_SIZE, HEADER_SIZE + length),
181+
error = "",
182+
)
183+
}
184+
185+
private fun post(path: String, body: String? = null): String {
186+
val connection = URL("$bridgeUrl$path").openConnection() as HttpURLConnection
187+
connection.requestMethod = "POST"
188+
connection.connectTimeout = CONNECT_TIMEOUT_MS
189+
connection.readTimeout = READ_TIMEOUT_MS
190+
connection.doInput = true
191+
192+
if (body != null) {
193+
connection.doOutput = true
194+
connection.setRequestProperty("Content-Type", "text/plain")
195+
connection.outputStream.use { it.write(body.toByteArray()) }
196+
}
197+
198+
val statusCode = connection.responseCode
199+
val response = if (statusCode in 200..299) {
200+
connection.inputStream.bufferedReader().use { it.readText() }
201+
} else {
202+
connection.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
203+
}
204+
connection.disconnect()
205+
206+
check(statusCode in 200..299) {
207+
"Bridge request '$path' failed with HTTP '$statusCode': $response"
208+
}
209+
return response
210+
}
211+
212+
private fun toBridgePath(path: String): String = "$BRIDGE_PATH_PREFIX$path"
213+
214+
private fun rawBridgePath(path: String): String = path.removePrefix(BRIDGE_PATH_PREFIX)
215+
216+
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.name())
217+
218+
@Serializable
219+
private data class BridgeDevice(
220+
val path: String,
221+
val session: String? = null,
222+
)
223+
224+
@Serializable
225+
private data class BridgeSession(
226+
val session: String,
227+
)
228+
}

app/src/main/java/to/bitkit/services/TrezorTransport.kt

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import javax.inject.Singleton
6666
@Singleton
6767
class TrezorTransport @Inject constructor(
6868
@ApplicationContext private val context: Context,
69+
private val bridgeTransport: TrezorBridgeTransport,
6970
) : TrezorTransportCallback {
7071

7172
companion object {
@@ -219,6 +220,12 @@ class TrezorTransport @Inject constructor(
219220
Logger.error("BLE enumerate failed", it, context = TAG)
220221
}
221222

223+
val bridgeDevices = bridgeTransport.enumerateDevices()
224+
devices.addAll(bridgeDevices)
225+
if (bridgeDevices.isNotEmpty()) {
226+
Logger.info("Found '${bridgeDevices.size}' Trezor Bridge device(s)", context = TAG)
227+
}
228+
222229
Logger.info("Total enumerate found '${devices.size}' Trezor device(s)", context = TAG)
223230
val summary = devices.map { "${it.path} (${it.transportType})" }
224231
TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: $summary")
@@ -227,7 +234,9 @@ class TrezorTransport @Inject constructor(
227234

228235
override fun openDevice(path: String): TrezorTransportWriteResult {
229236
TrezorDebugLog.log("OPEN", "openDevice: $path")
230-
return if (isBleDevice(path)) {
237+
return if (bridgeTransport.isBridgeDevice(path)) {
238+
bridgeTransport.openDevice(path)
239+
} else if (isBleDevice(path)) {
231240
openBleDevice(path)
232241
} else {
233242
openUsbDevice(path)
@@ -236,31 +245,39 @@ class TrezorTransport @Inject constructor(
236245

237246
override fun closeDevice(path: String): TrezorTransportWriteResult {
238247
TrezorDebugLog.log("CLOSE", "closeDevice: $path")
239-
return if (isBleDevice(path)) {
248+
return if (bridgeTransport.isBridgeDevice(path)) {
249+
bridgeTransport.closeDevice(path)
250+
} else if (isBleDevice(path)) {
240251
closeBleDevice(path)
241252
} else {
242253
closeUsbDevice(path)
243254
}
244255
}
245256

246257
override fun readChunk(path: String): TrezorTransportReadResult {
247-
return if (isBleDevice(path)) {
258+
return if (bridgeTransport.isBridgeDevice(path)) {
259+
bridgeTransport.readChunk(path)
260+
} else if (isBleDevice(path)) {
248261
readBleChunk(path)
249262
} else {
250263
readUsbChunk(path)
251264
}
252265
}
253266

254267
override fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult {
255-
return if (isBleDevice(path)) {
268+
return if (bridgeTransport.isBridgeDevice(path)) {
269+
bridgeTransport.writeChunk(path, data)
270+
} else if (isBleDevice(path)) {
256271
writeBleChunk(path, data)
257272
} else {
258273
writeUsbChunk(path, data)
259274
}
260275
}
261276

262277
override fun getChunkSize(path: String): UInt {
263-
return if (isBleDevice(path)) {
278+
return if (bridgeTransport.isBridgeDevice(path)) {
279+
USB_CHUNK_SIZE.toUInt()
280+
} else if (isBleDevice(path)) {
264281
BLE_CHUNK_SIZE.toUInt()
265282
} else {
266283
USB_CHUNK_SIZE.toUInt()
@@ -272,6 +289,10 @@ class TrezorTransport @Inject constructor(
272289
messageType: UShort,
273290
data: ByteArray,
274291
): TrezorCallMessageResult? {
292+
if (bridgeTransport.isBridgeDevice(path)) {
293+
return bridgeTransport.callMessage(path, messageType, data)
294+
}
295+
275296
// For BLE/THP devices, the Rust side now handles THP protocol directly.
276297
// This callback returns null to let Rust use its built-in THP implementation.
277298
Logger.debug(

0 commit comments

Comments
 (0)