Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@
tools:ignore="ForegroundServicePermission,ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<!-- USB Host support for Trezor hardware wallet -->
<!-- USB Host support for hardware wallets (Trezor, Blockstream Jade) -->
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />

<!-- Bluetooth permissions for Trezor Safe 7 (Android 12+) -->
<!-- Bluetooth permissions for hardware wallets (Trezor Safe 7, Blockstream Jade) on Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
Expand Down Expand Up @@ -150,7 +150,7 @@
<data android:scheme="lnurlp" />
</intent-filter>

<!-- USB device attached — auto-grants permission when Trezor is plugged in -->
<!-- USB device attached — auto-grants permission when a hardware wallet is plugged in -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.env.Env
import to.bitkit.repositories.HwWalletRepo
import to.bitkit.services.BluetoothInit
import to.bitkit.services.PubkyAuthHandlerRegistrar
import to.bitkit.utils.Logger
Expand All @@ -32,6 +37,10 @@ internal open class App : Application(), Configuration.Provider {
@Inject
lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar

/** Resolved only once the process changes foreground state, so startup does not build the wallet graph. */
@Inject
lateinit var hwWalletRepo: Lazy<HwWalletRepo>

override val workManagerConfiguration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
Expand All @@ -47,6 +56,19 @@ internal open class App : Application(), Configuration.Provider {
// Initialize btleplug for Bluetooth support (required before any BLE usage)
BluetoothInit.ensureInitialized()
pubkyAuthHandlerRegistrar.start()
observeAppForeground()
}

private fun observeAppForeground() {
ProcessLifecycleOwner.get().lifecycle.addObserver(
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> hwWalletRepo.get().onAppForegrounded()
Lifecycle.Event.ON_STOP -> hwWalletRepo.get().onAppBackgrounded()
else -> Unit
}
},
)
}

private fun installUncaughtExceptionLogger() {
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/to/bitkit/data/HwWalletStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import to.bitkit.data.serializers.HwWalletDataSerializer
import to.bitkit.di.IoDispatcher
import to.bitkit.models.HwWalletVendor
import to.bitkit.models.KnownDevice
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -29,22 +30,27 @@ class HwWalletStore @Inject constructor(

val data: Flow<HwWalletData> = store.data

suspend fun loadKnownDevices(): List<KnownDevice> = withContext(ioDispatcher) {
store.data.first().knownDevices
/** @param vendor when given, only that vendor's entries are returned. */
suspend fun loadKnownDevices(vendor: HwWalletVendor? = null): List<KnownDevice> = withContext(ioDispatcher) {
store.data.first().knownDevices.filter { vendor == null || it.vendor == vendor }
}

/**
* @param pendingName a pending-name change to apply in the same write, or null to leave them alone.
* Splitting the two would publish a device list without its matching name change, which restarts a
* watcher for a wallet already being removed and can leave a name in both places or in neither.
* @param vendor when given, [devices] replaces only that vendor's entries and the other vendors'
* entries are kept, so each vendor repo can write its own view without dropping the others'.
*/
suspend fun saveKnownDevices(
devices: List<KnownDevice>,
pendingName: PendingNameUpdate? = null,
vendor: HwWalletVendor? = null,
) = withContext(ioDispatcher) {
store.updateData { data ->
val kept = if (vendor == null) emptyList() else data.knownDevices.filter { it.vendor != vendor }
data.copy(
knownDevices = devices,
knownDevices = kept + devices,
pendingNames = pendingName?.applyTo(data.pendingNames) ?: data.pendingNames,
)
}
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/ext/HwExceptionExt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package to.bitkit.ext

/** Vendor-neutral views over the Trezor and Jade error predicates, for code shared by every vendor. */
fun Throwable.isHwUserCancellation(): Boolean = isTrezorUserCancellation() || isJadeUserCancellation()

fun Throwable.isHwDeviceBusy(): Boolean = isTrezorDeviceBusy() || isJadeDeviceBusy()

fun Throwable.isHwFirmwareError(): Boolean = isTrezorFirmwareError() || isJadeFirmwareError()

fun Throwable.isHwSessionFailure(): Boolean = isTrezorSessionFailure() || isJadeSessionFailure()
29 changes: 29 additions & 0 deletions app/src/main/java/to/bitkit/ext/JadeExceptionExt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package to.bitkit.ext

import com.synonym.bitkitcore.JadeException

fun Throwable.isJadeUserCancellation(): Boolean =
generateSequence(this) { it.cause }.any { it is JadeException.UserCancelled }

/** The device cannot serve the request until the user acts on it: busy with another prompt, or locked. */
fun Throwable.isJadeDeviceBusy(): Boolean =
generateSequence(this) { it.cause }.any { it is JadeException.DeviceBusy || it is JadeException.DeviceLocked }

fun Throwable.isJadeFirmwareError(): Boolean =
generateSequence(this) { it.cause }.any { it is JadeException.UnsupportedFirmware }

fun Throwable.isJadeSessionFailure(): Boolean =
generateSequence(this) { it.cause }.any {
when (it) {
is JadeException.TransportException,
is JadeException.DeviceDisconnected,
is JadeException.ConnectionException,
is JadeException.Timeout,
is JadeException.NotConnected,
is JadeException.NotInitialized,
is JadeException.IoException,
-> true

else -> false
}
}
76 changes: 74 additions & 2 deletions app/src/main/java/to/bitkit/models/HardwareWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import androidx.compose.runtime.Stable
import com.synonym.bitkitcore.AccountType
import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.JadeAddressVariant
import com.synonym.bitkitcore.TrezorScriptType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.serialization.Serializable

Expand All @@ -24,6 +26,7 @@ data class HwWallet(
val fundingBalanceSats: ULong = balanceSats,
val deviceIds: ImmutableSet<String> = persistentSetOf(id),
val passphraseProtected: Boolean = false,
val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
)

/** Serializable per-device balance snapshot carried by [BalanceState]. */
Expand Down Expand Up @@ -66,6 +69,16 @@ sealed interface HwFundingAccount {
override val accountType: AccountType
get() = addressType.accountType
}

data class Jade(
override val xpub: String,
override val addressType: HwFundingAddressType,
override val balanceSats: ULong,
) : HwFundingAccount {
override val vendor: HwWalletVendor = HwWalletVendor.BLOCKSTREAM
override val accountType: AccountType
get() = addressType.accountType
}
}

data class HwFundingTransaction(
Expand All @@ -90,8 +103,56 @@ data class HwFundingBroadcastResult(
val totalSpent: ULong,
)

enum class HwWalletVendor {
TREZOR,
/**
* Hardware wallet makers Bitkit can pair with. [deviceType] is the wallet-id namespace passed to
* bitkit-core's `deriveWalletId`, so it must stay stable once entries are persisted.
*/
enum class HwWalletVendor(val deviceType: String) {
TREZOR("trezor"),
BLOCKSTREAM("jade"),
}

/** A device found by discovery that is not paired yet, across every vendor. */
@Immutable
data class HwNearbyDevice(
val vendor: HwWalletVendor,
val id: String,
val path: String,
val transportType: TransportType,
val name: String? = null,
val model: String? = null,
)

/** The device holding the live session, across every vendor. */
@Immutable
data class HwConnectedDevice(
val vendor: HwWalletVendor,
val id: String,
val label: String? = null,
val model: String? = null,
/** Identity the live session was opened for; a Trezor can hold several passphrase wallets. */
val walletId: String? = null,
val passphraseProtection: Boolean = false,
/** The device needs its PIN before it can sign; a Jade locks on every power cycle. */
val isLocked: Boolean = false,
)

/** Discovery and connection state of every hardware-wallet vendor, merged for the UI. */
@Immutable
data class HwDeviceState(
val isScanning: Boolean = false,
val isConnecting: Boolean = false,
val isAutoReconnecting: Boolean = false,
/** A Jade is waiting for its PIN to be entered on the device. */
val isUnlocking: Boolean = false,
val knownDevices: ImmutableList<KnownDevice> = persistentListOf(),
val nearbyDevices: ImmutableList<HwNearbyDevice> = persistentListOf(),
val connected: HwConnectedDevice? = null,
val error: String? = null,
) {
fun connectedDeviceId(): String? = connected?.id

fun connectedWalletId(): String? = connected?.walletId
}

enum class HwFundingAddressType(
Expand All @@ -116,8 +177,19 @@ enum class HwFundingAddressType(
TAPROOT -> TrezorScriptType.SPEND_TAPROOT
}

val jadeVariant: JadeAddressVariant
get() = when (this) {
LEGACY -> JadeAddressVariant.PKH
NESTED_SEGWIT -> JadeAddressVariant.SH_WPKH
NATIVE_SEGWIT -> JadeAddressVariant.WPKH
TAPROOT -> JadeAddressVariant.TR
}

companion object {
val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE }

fun fromJadeVariant(variant: JadeAddressVariant): HwFundingAddressType =
entries.first { it.jadeVariant == variant }
}
}

Expand Down
71 changes: 70 additions & 1 deletion app/src/main/java/to/bitkit/models/KnownDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,73 @@ data class KnownDevice(
* that report a different one belong to a seed the device can no longer sign for.
*/
val trezorDeviceId: String? = null,
)
/** Entries stored before other vendors existed carry no vendor and are Trezor ones. */
val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
/** The Jade's efuse MAC: the one identifier that survives a USB replug, which renumbers [path]. */
val jadeDeviceId: String? = null,
) {
/** The vendor's own stable device identifier, when the device reported one. */
val hardwareId: String?
get() = when (vendor) {
HwWalletVendor.TREZOR -> trezorDeviceId
HwWalletVendor.BLOCKSTREAM -> jadeDeviceId
}
}

internal fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId

/**
* Cross-transport identity of the wallet a device entry tracks: entries created by pairing the same
* physical device over different transports share the same xpubs. Entries without captured xpubs fall
* back to their own transport-level id.
*/
internal val KnownDevice.walletKey: String
get() = walletKey(xpubs, id)

internal fun walletKey(xpubs: Map<String, String>, fallback: String): String =
xpubs.values.sorted().joinToString().ifEmpty { fallback }

/**
* Whether a stored entry gives way to the one just read. That covers the identity it holds and the
* entry this connect refreshed, since reading a previously rejected address type changes the
* walletKey and matching on the new key alone would leave the old entry behind as a duplicate.
* Wallets of a seed the device no longer carries go too: nothing would ever supersede them by key
* material. An unknown device id proves nothing, so those entries are left alone.
*/
internal fun KnownDevice.isReplacedBy(known: KnownDevice, refreshed: KnownDevice?): Boolean {
if (id != known.id) return false
if (walletKey == known.walletKey) return true
if (refreshed != null && walletKey == refreshed.walletKey) return true
return known.hardwareId != null && hardwareId != null && hardwareId != known.hardwareId
}

internal fun deriveHardwareWalletId(xpubs: Map<String, String>, vendor: HwWalletVendor): String? =
if (xpubs.isEmpty()) {
null
} else {
runCatching { HwWalletId.derive(xpubs, deviceType = vendor.deviceType) }.getOrNull()
}

internal fun List<KnownDevice>.findHardwareWalletId(
xpubs: Map<String, String>,
fallback: String,
vendor: HwWalletVendor,
): String {
val walletKey = walletKey(xpubs, fallback)
return firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() }
?: deriveHardwareWalletId(xpubs, vendor).orEmpty()
}

internal fun List<KnownDevice>.withHardwareWalletIds(): List<KnownDevice> {
val existingByWallet = filter { it.walletId.isNotBlank() }
.associate { it.walletKey to it.walletId }
val generatedByWallet = mutableMapOf<String, String>()

return map {
val walletId = existingByWallet[it.walletKey]
?: generatedByWallet.getOrPut(it.walletKey) {
deriveHardwareWalletId(it.xpubs, it.vendor).orEmpty()
}
if (it.walletId == walletId) it else it.copy(walletId = walletId)
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/models/Network.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package to.bitkit.models

import com.synonym.bitkitcore.JadeNetwork
import com.synonym.bitkitcore.NetworkType
import com.synonym.bitkitcore.TrezorCoinType
import org.lightningdevkit.ldknode.Network
import to.bitkit.utils.AppError
import com.synonym.bitkitcore.Network as BitkitCoreNetwork

fun Network.networkUiText(): String = when (this) {
Expand All @@ -19,6 +21,14 @@ fun Network.toTrezorCoinType(): TrezorCoinType = when (this) {
Network.REGTEST -> TrezorCoinType.REGTEST
}

/** Jade has no signet; its regtest is named "localtest" on the wire and is mapped by bitkit-core. */
fun Network.toJadeNetwork(): JadeNetwork = when (this) {
Network.BITCOIN -> JadeNetwork.MAINNET
Network.TESTNET -> JadeNetwork.TESTNET
Network.REGTEST -> JadeNetwork.REGTEST
Network.SIGNET -> throw AppError("Signet is not supported by Jade")
}

fun Network.toCoreNetwork(): BitkitCoreNetwork = when (this) {
Network.BITCOIN -> BitkitCoreNetwork.BITCOIN
Network.TESTNET -> BitkitCoreNetwork.TESTNET
Expand Down
Loading