diff --git a/.agents/commands/pr.md b/.agents/commands/pr.md index 4c49f680dc..7660aef2db 100644 --- a/.agents/commands/pr.md +++ b/.agents/commands/pr.md @@ -136,7 +136,8 @@ When the user provides custom instructions after `--`: #### Automated Checks ``` - Keep local verification commands, Gradle tasks, detekt, lint, unit tests, build passes, cargo test, cargo clippy, npm test, typecheck, CI coverage, or similar automated checks out of `#### Manual Tests`; summarize them under `#### Automated Checks` when they add useful context. -- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed with file paths and a short explanation. +- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed, each with the test file name and a short explanation. +- Reference test files by bare file name only (e.g. `HwWalletRepoTest.kt`), never the full path. Only when two referenced test files share the same name, prefix the shortest leading path segment(s) that disambiguate them (e.g. `repositories/FooTest.kt` vs `viewmodels/FooTest.kt`). - For removed automated coverage, state why it was removed. - Do not list standard CI or PR bot commands as checkbox items just because they run for every PR. If standard CI coverage is worth mentioning, summarize it in one sentence. - List raw commands only when they were run locally, are non-standard, use special flags or environment values, validate workflow behavior, or explain a meaningful verification gap. @@ -184,9 +185,9 @@ Concrete style target: - [ ] **5b.** back: returns to Connections List. - [ ] **6.** `regression:` Channel Detail → tap Close Connection: works. #### Automated Checks -- Unit tests added: cover invoice timeout handling in `app/src/test/.../SendInvoiceTest.kt`. -- Unit tests modified: update channel navigation assertions in `app/src/test/.../ChannelDetailTest.kt`. -- Test coverage removed: delete stale mock-only assertions from `app/src/test/.../OldFlowTest.kt` because the flow no longer exists. +- Unit tests added: cover invoice timeout handling in `SendInvoiceTest.kt`. +- Unit tests modified: update channel navigation assertions in `ChannelDetailTest.kt`. +- Test coverage removed: delete stale mock-only assertions from `OldFlowTest.kt` because the flow no longer exists. - CI: standard compile, unit test, and detekt checks run by the PR bot. ``` diff --git a/AGENTS.md b/AGENTS.md index 44f2bc3cc8..51ae68915c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS add new localizable string resources in alphabetical order in `strings.xml` - NEVER add string resources for strings used only in dev settings screens and previews and never localize acronyms - ALWAYS use template in `.github/pull_request_template.md` for PR descriptions +- ALWAYS reference test files in PR descriptions/QA Notes by bare file name only (e.g. `HwWalletRepoTest.kt`), NEVER the full path; only when two referenced test files share the same name, prefix the shortest leading path segment(s) that disambiguate them (e.g. `repositories/FooTest.kt` vs `viewmodels/FooTest.kt`) - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - PREFER to use one-liners with `run {}` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names diff --git a/app/src/main/java/to/bitkit/data/TrezorStore.kt b/app/src/main/java/to/bitkit/data/HwWalletStore.kt similarity index 75% rename from app/src/main/java/to/bitkit/data/TrezorStore.kt rename to app/src/main/java/to/bitkit/data/HwWalletStore.kt index 2757cd1dec..79f0cc58cc 100644 --- a/app/src/main/java/to/bitkit/data/TrezorStore.kt +++ b/app/src/main/java/to/bitkit/data/HwWalletStore.kt @@ -9,25 +9,25 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import to.bitkit.data.serializers.TrezorDataSerializer +import to.bitkit.data.serializers.HwWalletDataSerializer import to.bitkit.di.IoDispatcher import to.bitkit.repositories.KnownDevice import javax.inject.Inject import javax.inject.Singleton -private val Context.trezorDataStore: DataStore by dataStore( +private val Context.hwWalletDataStore: DataStore by dataStore( fileName = "trezor_device.json", - serializer = TrezorDataSerializer + serializer = HwWalletDataSerializer ) @Singleton -class TrezorStore @Inject constructor( +class HwWalletStore @Inject constructor( @ApplicationContext private val context: Context, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { - private val store = context.trezorDataStore + private val store = context.hwWalletDataStore - val data: Flow = store.data + val data: Flow = store.data suspend fun loadKnownDevices(): List = withContext(ioDispatcher) { store.data.first().knownDevices @@ -39,12 +39,12 @@ class TrezorStore @Inject constructor( } suspend fun reset() = withContext(ioDispatcher) { - store.updateData { TrezorData() } + store.updateData { HwWalletData() } Unit } } @Serializable -data class TrezorData( +data class HwWalletData( val knownDevices: List = emptyList(), ) diff --git a/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/HwWalletDataSerializer.kt similarity index 53% rename from app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt rename to app/src/main/java/to/bitkit/data/serializers/HwWalletDataSerializer.kt index 7a93a59f65..6f84047a11 100644 --- a/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt +++ b/app/src/main/java/to/bitkit/data/serializers/HwWalletDataSerializer.kt @@ -2,27 +2,27 @@ package to.bitkit.data.serializers import androidx.datastore.core.Serializer import kotlinx.serialization.SerializationException -import to.bitkit.data.TrezorData +import to.bitkit.data.HwWalletData import to.bitkit.di.json import to.bitkit.utils.Logger import java.io.InputStream import java.io.OutputStream -object TrezorDataSerializer : Serializer { - private const val TAG = "TrezorDataSerializer" +object HwWalletDataSerializer : Serializer { + private const val TAG = "HwWalletDataSerializer" - override val defaultValue: TrezorData = TrezorData() + override val defaultValue: HwWalletData = HwWalletData() - override suspend fun readFrom(input: InputStream): TrezorData { + override suspend fun readFrom(input: InputStream): HwWalletData { return try { json.decodeFromString(input.readBytes().decodeToString()) } catch (e: SerializationException) { - Logger.error("Deserialize Trezor data failed", e, context = TAG) + Logger.error("Deserialize hardware wallet data failed", e, context = TAG) defaultValue } } - override suspend fun writeTo(t: TrezorData, output: OutputStream) { + override suspend fun writeTo(t: HwWalletData, output: OutputStream) { output.write(json.encodeToString(t).encodeToByteArray()) } } diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index 7aa39ac7c4..b4a8d5e24a 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -2,6 +2,7 @@ package to.bitkit.models +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AddressType import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env @@ -99,6 +100,14 @@ fun AddressType.toAccountDerivationPath(network: Network = Env.network): String } } +fun AddressType.toAccountType(): AccountType = when (this) { + AddressType.P2TR -> AccountType.TAPROOT + AddressType.P2WPKH -> AccountType.NATIVE_SEGWIT + AddressType.P2SH -> AccountType.WRAPPED_SEGWIT + AddressType.P2PKH -> AccountType.LEGACY + else -> AccountType.NATIVE_SEGWIT +} + fun AddressType.toSettingsString(): String = when (this) { AddressType.P2TR -> "taproot" AddressType.P2WPKH -> "nativeSegwit" diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 5907238e87..9bfb8b4cd1 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -1,9 +1,9 @@ package to.bitkit.models -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable -@Immutable +@Stable @Serializable data class BalanceState( val totalOnchainSats: ULong = 0uL, @@ -13,6 +13,11 @@ data class BalanceState( val maxSendOnchainSats: ULong = 0uL, val balanceInTransferToSavings: ULong = 0uL, val balanceInTransferToSpending: ULong = 0uL, + val hardwareWallets: List = emptyList(), ) { val totalSats get() = totalOnchainSats + totalLightningSats + + val totalHardwareSats get() = hardwareWallets.fold(0uL) { acc, wallet -> acc.safe() + wallet.sats.safe() } + + val totalWithHardwareSats get() = totalSats.safe() + totalHardwareSats.safe() } diff --git a/app/src/main/java/to/bitkit/models/HwWallet.kt b/app/src/main/java/to/bitkit/models/HwWallet.kt new file mode 100644 index 0000000000..f6fffd490e --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwWallet.kt @@ -0,0 +1,36 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.synonym.bitkitcore.Activity +import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.Serializable + +/** A paired hardware wallet tracked as a watch-only balance. */ +@Stable +data class HwWallet( + val id: String, + val name: String, + val model: String?, + val transportType: TransportType, + val isConnected: Boolean, + val balanceSats: ULong, + val activities: ImmutableList, +) + +/** Serializable per-device balance snapshot carried by [BalanceState]. */ +@Immutable +@Serializable +data class HwWalletBalance( + val id: String, + val sats: ULong, +) + +/** A newly detected inbound transaction to a watched hardware wallet. */ +@Immutable +data class HwWalletReceivedTx( + val txid: String, + val sats: ULong, +) + +fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index 8ae3c576de..c66ee2ffb5 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -19,6 +19,12 @@ enum class Suggestion( color = Colors.Brand24, icon = R.drawable.b_emboss, ), + HARDWARE( + title = R.string.cards__hardware__title, + description = R.string.cards__hardware__description, + color = Colors.Blue24, + icon = R.drawable.trezor, + ), LIGHTNING( title = R.string.cards__lightning__title, description = R.string.cards__lightning__description, @@ -46,13 +52,13 @@ enum class Suggestion( INVITE( title = R.string.cards__invite__title, description = R.string.cards__invite__description, - color = Colors.Blue24, + color = Colors.Green24, icon = R.drawable.group ), PROFILE( title = R.string.cards__slashtagsProfile__title, description = R.string.cards__slashtagsProfile__description, - color = Colors.PubkyGreen24, + color = Colors.Brand24, icon = R.drawable.crown, ), SHOP( diff --git a/app/src/main/java/to/bitkit/models/TransportType.kt b/app/src/main/java/to/bitkit/models/TransportType.kt new file mode 100644 index 0000000000..df82a66005 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/TransportType.kt @@ -0,0 +1,14 @@ +package to.bitkit.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Transport a hardware-wallet device is paired over. */ +@Serializable +enum class TransportType { + @SerialName("bluetooth") + BLUETOOTH, + + @SerialName("usb") + USB, +} diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt new file mode 100644 index 0000000000..64287adad4 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -0,0 +1,250 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TxDirection +import com.synonym.bitkitcore.WatcherEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.data.HwWalletStore +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.ext.rawId +import to.bitkit.models.HwWallet +import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.TransportType +import to.bitkit.models.toAccountType +import to.bitkit.models.toAddressType +import to.bitkit.models.toCoreNetwork +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Production hardware-wallet business layer. Tracks paired Trezor devices as + * watch-only balances by running one on-chain xpub watcher per (device, address type) + * and exposing the aggregated per-device balance and activity to the UI. + * + * Built on top of [TrezorRepo], which owns the device list, connect orchestration + * and the underlying watcher transport. + */ +@OptIn(ExperimentalTime::class) +@Singleton +class HwWalletRepo @Inject constructor( + private val trezorRepo: TrezorRepo, + private val hwWalletStore: HwWalletStore, + private val settingsStore: SettingsStore, + private val clock: Clock, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + companion object { + private const val WATCHER_ID_SEPARATOR = "|" + } + + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + + private val activeWatchers = mutableSetOf() + private val _watcherData = MutableStateFlow>(emptyMap()) + + private val _receivedTxs = MutableSharedFlow(extraBufferCapacity = 8) + + /** Inbound transactions detected by a running watcher after its initial history sync. */ + val receivedTxs: SharedFlow = _receivedTxs.asSharedFlow() + + /** Forwards UI-delivered transport events, e.g. the USB attach intent from the OS app picker. */ + fun onTransportRestored(transportType: TransportType) = trezorRepo.onTransportRestored(transportType) + + /** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */ + val needsPairingCode = trezorRepo.needsPairingCode + + fun submitPairingCode(code: String) = trezorRepo.submitPairingCode(code) + + fun cancelPairingCode() = trezorRepo.cancelPairingCode() + + val wallets: StateFlow> = combine( + hwWalletStore.data, + trezorRepo.state, + _watcherData, + ) { data, trezorState, watcherData -> + // The same physical device paired over both bluetooth and usb is stored as two + // entries with different transport-level ids; its xpubs are the cross-transport + // identity, so group by them to show one wallet and count its balance once. + data.knownDevices + .groupBy { it.walletKey } + .map { (_, devices) -> + val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId } + val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } + val ids = devices.map { it.id }.toSet() + val deviceWatchers = watcherData.values.filter { it.deviceId in ids } + HwWallet( + id = device.id, + name = device.displayName, + model = device.model, + transportType = device.transportType, + isConnected = connectedDevice != null, + balanceSats = deviceWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }, + activities = deviceWatchers.flatMap { it.activities } + .distinctBy { it.rawId() } + .toImmutableList(), + ) + } + .toImmutableList() + }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + val totalSats: StateFlow = wallets + .map { wallets -> wallets.fold(0uL) { acc, wallet -> acc + wallet.balanceSats } } + .stateIn(scope, SharingStarted.Eagerly, 0uL) + + val activities: StateFlow> = wallets + .map { wallets -> wallets.flatMap { it.activities }.toImmutableList() } + .stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + init { + observeWatcherEvents() + syncWatchers() + } + + private fun observeWatcherEvents() { + scope.launch { + trezorRepo.watcherEvents.collect { (watcherId, event) -> + if (event !is WatcherEvent.TransactionsChanged) return@collect + val previous = _watcherData.value[watcherId] + val activities = event.transactions.map { it.toOnchainActivity(clock) }.toImmutableList() + _watcherData.update { + it + (watcherId to HwWatcherData(watcherId.toDeviceId(), event.balance.total, activities)) + } + emitReceivedTxs(previous, event) + } + } + } + + /** + * The first event after a watcher starts delivers the full transaction history; + * treat it as the baseline so only transactions arriving while watching are emitted. + */ + private suspend fun emitReceivedTxs(previous: HwWatcherData?, event: WatcherEvent.TransactionsChanged) { + if (previous == null) return + val knownTxIds = previous.activities.map { it.rawId() }.toSet() + event.transactions + .filter { it.direction == TxDirection.RECEIVED && it.txid !in knownTxIds } + .forEach { _receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = it.amount)) } + } + + private fun syncWatchers() { + scope.launch { + combine( + hwWalletStore.data, + settingsStore.data.map { it.addressTypesToMonitor.toSet() }.distinctUntilChanged(), + ) { data, monitoredTypes -> + data.knownDevices to monitoredTypes + }.collect { (knownDevices, monitoredTypes) -> + // Only watch the address types the user monitors (Settings > Advanced > Address Type), + // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so + // toggling a type on later starts its watcher without reconnecting the device. + // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. + val filtered = knownDevices.flatMap { device -> + device.xpubs + .filterKeys { it in monitoredTypes } + .map { (addressType, xpub) -> WatcherSpec(device.id, addressType, xpub) } + }.distinctBy { it.addressType to it.xpub } + val filteredIds = filtered.map { it.watcherId }.toSet() + + filtered.forEach { spec -> + if (spec.watcherId in activeWatchers) return@forEach + trezorRepo.startWatcher( + watcherId = spec.watcherId, + extendedKey = spec.xpub, + network = Env.network.toCoreNetwork(), + accountType = spec.addressType.toAddressType()?.toAccountType(), + ).onSuccess { activeWatchers += spec.watcherId } + } + + // A failed stop stays active so the next sync retries it; dropping it here + // would leave the orphaned watcher feeding _watcherData as a ghost balance. + (activeWatchers - filteredIds).forEach { staleId -> + trezorRepo.stopWatcher(staleId).onSuccess { + activeWatchers -= staleId + _watcherData.update { it - staleId } + } + } + } + } + } + + private fun HistoryTransaction.toOnchainActivity(clock: Clock): Activity { + val type = when (direction) { + TxDirection.RECEIVED -> PaymentType.RECEIVED + TxDirection.SENT, TxDirection.SELF_TRANSFER -> PaymentType.SENT + } + val activityTimestamp = timestamp ?: clock.now().epochSeconds.toULong() + return Activity.Onchain( + OnchainActivity.create( + id = txid, + txType = type, + txId = txid, + value = amount, + fee = fee ?: 0uL, + address = "", + timestamp = activityTimestamp, + confirmed = confirmations > 0u, + ) + ) + } + + private data class WatcherSpec( + val deviceId: String, + val addressType: String, + val xpub: String, + ) { + val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType" + } + + private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) +} + +/** + * 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. + */ +private val KnownDevice.walletKey: String + get() = xpubs.values.sorted().joinToString().ifEmpty { id } + +/** + * The label is the user-set name stored on the device itself; without one (or with the + * factory default that just mirrors the model), fall back to the vendor-prefixed model + * (e.g. "Safe 7" reads as "Trezor Safe 7"). + */ +private val KnownDevice.displayName: String + get() { + label?.takeIf { it != model }?.let { return it } + val model = model ?: return "Trezor" + return if (model.startsWith("Trezor")) model else "Trezor $model" + } + +private data class HwWatcherData( + val deviceId: String, + val balanceSats: ULong, + val activities: ImmutableList, +) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 5f7b467167..37240130e9 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -31,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -43,12 +44,17 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import to.bitkit.data.TrezorStore +import to.bitkit.data.HwWalletStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.nowMs +import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.TransportType +import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toSettingsString +import to.bitkit.models.toTrezorCoinType import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -59,16 +65,21 @@ import to.bitkit.utils.Logger import java.io.File import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime import com.synonym.bitkitcore.Network as BitkitCoreNetwork -@Suppress("TooManyFunctions") +@OptIn(ExperimentalTime::class) +@Suppress("TooManyFunctions", "LongParameterList") @Singleton class TrezorRepo @Inject constructor( @ApplicationContext private val context: Context, private val trezorService: TrezorService, private val trezorTransport: TrezorTransport, private val trezorUiHandler: TrezorUiHandler, - private val trezorStore: TrezorStore, + private val hwWalletStore: HwWalletStore, + private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -77,12 +88,22 @@ class TrezorRepo @Inject constructor( private const val DEFAULT_ADDRESS_PATH = "m/84'/0'/0'/0/0" private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'" private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L + private const val TRANSPORT_RESTORED_MAX_ATTEMPTS = 4 + private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds } private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() - private val watcherCleanupScope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + + @Volatile + private var transportReconnectJob: Job? = null + + init { + observeExternalDisconnects() + observeTransportRestored() + } private val _watcherEvents = MutableSharedFlow>(extraBufferCapacity = 64) val watcherEvents: SharedFlow> = _watcherEvents.asSharedFlow() @@ -468,7 +489,16 @@ class TrezorRepo @Inject constructor( fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() - suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(ioDispatcher) { + suspend fun autoReconnect( + walletIndex: Int = 0, + preferredTransport: TransportType? = null, + ): Result = withContext(ioDispatcher) { + if (isConnectInProgress()) { + // A live handshake looks like a stale session (transport connected, + // features pending), so resetting here would drop the session the + // user is entering their PIN or pairing code into. + return@withContext Result.failure(AppError("Connect already in progress")) + } val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } if (knownDevices.isEmpty()) { return@withContext Result.failure(AppError("No known devices")) @@ -479,9 +509,15 @@ class TrezorRepo @Inject constructor( if (!_state.value.isInitialized) { initialize(walletIndex).getOrThrow() } - if (trezorService.isConnected()) { - _state.value.connectedDevice ?: throw AppError("Connected but no features") + val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice else null + if (cachedFeatures != null) { + cachedFeatures } else { + if (trezorService.isConnected()) { + // The transport dropped underneath the session (e.g. bluetooth was + // toggled), so reset it before a fresh scan and connect. + runCatching { trezorService.disconnect() } + } val scannedDevices = scan().getOrThrow() val knownIds = knownDevices.map { it.id }.toSet() val usbDevice = scannedDevices.find { @@ -490,7 +526,15 @@ class TrezorRepo @Inject constructor( val idMatch = knownDevices.firstNotNullOfOrNull { known -> scannedDevices.find { it.id == known.id } } - val match = idMatch ?: usbDevice ?: throw AppError("No known device found nearby") + // Prefer the transport that just came back, so e.g. a USB replug does + // not reconnect over BLE when the same device is known on both. + val preferredMatch = preferredTransport?.let { preferred -> + scannedDevices.find { + it.id in knownIds && it.transportType.toTransportType() == preferred + } + } + val match = preferredMatch ?: idMatch ?: usbDevice + ?: throw AppError("No known device found nearby") connect(match.id).getOrThrow() } }.onSuccess { @@ -609,7 +653,7 @@ class TrezorRepo @Inject constructor( } fun stopWatcherOnCleared(watcherId: String) { - watcherCleanupScope.launch { stopWatcher(watcherId) } + scope.launch { stopWatcher(watcherId) } } suspend fun stopAllWatchers(): Result = withContext(ioDispatcher) { @@ -626,7 +670,7 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(error = null) } } - fun observeExternalDisconnects(scope: CoroutineScope) { + private fun observeExternalDisconnects() { trezorTransport.externalDisconnect.onEach { path -> val currentId = _state.value.connectedDeviceId ?: return@onEach val knownDevice = _state.value.knownDevices.find { it.path == path } @@ -639,31 +683,107 @@ class TrezorRepo @Inject constructor( }.launchIn(scope) } + /** + * Silently reconnects to a known device when its transport comes back: stored THP + * credentials make the connect prompt-free, so the link indicator recovers on its + * own after Bluetooth is re-enabled or the device is plugged back in. + */ + private fun observeTransportRestored() { + trezorTransport.transportRestored.onEach { + launchTransportReconnect(it) + }.launchIn(scope) + } + + /** + * Triggers the silent reconnect for transport events delivered through UI intents, + * e.g. the USB attach intent the OS app picker routes to the activity (attach is + * not broadcast to receivers, unlike detach). + */ + fun onTransportRestored(transportType: TransportType) = launchTransportReconnect(transportType) + + /** + * Serializes reconnect triggers into one in-flight retry loop. A Trezor + * re-enumerates USB during its unlock flow, so a single replug delivers several + * attach intents; letting each spawn its own loop staggers connect attempts for + * many seconds, and every attempt restarts the device's PIN entry. + */ + private fun launchTransportReconnect(transportType: TransportType) { + if (transportReconnectJob?.isActive == true) return + transportReconnectJob = scope.launch { retryAutoReconnect(transportType) } + } + + /** + * A device is often not discoverable right after its transport returns (a BLE + * Trezor takes a few seconds to advertise again), so retry the silent reconnect + * with growing delays instead of giving up on the first empty scan. + */ + private suspend fun retryAutoReconnect(transportType: TransportType) { + repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt -> + if (_state.value.connected != null || isConnectInProgress()) return + delay(TRANSPORT_RESTORED_RECONNECT_DELAY * (attempt + 1)) + // A connect may have started while this attempt was waiting. + if (_state.value.connected != null || isConnectInProgress()) return + Logger.info("Attempting auto-reconnect after transport restored, attempt '${attempt + 1}'", context = TAG) + if (autoReconnect(preferredTransport = transportType).isSuccess) return + } + } + + private fun isConnectInProgress(): Boolean = run { + val current = _state.value + current.isConnecting || + current.isAutoReconnecting || + needsPinEntry.value || + needsPairingCode.value + } + private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { val existing = _state.value.knownDevices + val previous = existing.find { it.id == deviceInfo.id } val known = KnownDevice( id = deviceInfo.id, name = deviceInfo.name, path = deviceInfo.path, - transportType = deviceInfo.transportType.toKnownTransportType(), + transportType = deviceInfo.transportType.toTransportType(), label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, - lastConnectedAt = System.currentTimeMillis(), + lastConnectedAt = clock.nowMs(), + xpubs = fetchAccountXpubs().ifEmpty { previous?.xpubs ?: emptyMap() }, ) val updated = existing.filter { it.id != known.id } + known saveKnownDevices(updated) _state.update { it.copy(knownDevices = updated.toImmutableList()) } } + /** + * Reads account-level extended public keys for every supported address type so a + * watch-only balance can be tracked later without the device present. Failures are + * swallowed per type: a missing xpub just means that address type is not watched. + */ + private suspend fun fetchAccountXpubs(): Map { + val coin = Env.network.toTrezorCoinType() + return ALL_ADDRESS_TYPES.mapNotNull { addressType -> + runCatching { + val xpub = trezorService.getPublicKey( + path = addressType.toAccountDerivationPath(network = Env.network), + coin = coin, + showOnTrezor = false, + ).xpub + addressType.toSettingsString() to xpub + }.onFailure { + Logger.warn("Could not read xpub for '${addressType.toSettingsString()}'", it, context = TAG) + }.getOrNull() + }.toMap() + } + private suspend fun loadKnownDevices(): List = runCatching { - trezorStore.loadKnownDevices() + hwWalletStore.loadKnownDevices() }.onFailure { Logger.error("Failed to load known devices", it, context = TAG) }.getOrDefault(emptyList()) private suspend fun saveKnownDevices(devices: List) { runCatching { - trezorStore.saveKnownDevices(devices) + hwWalletStore.saveKnownDevices(devices) }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } @@ -774,27 +894,20 @@ data class KnownDevice( val id: String, val name: String?, val path: String, - val transportType: KnownDeviceTransportType, + val transportType: TransportType, val label: String?, val model: String?, val lastConnectedAt: Long, + /** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */ + val xpubs: Map = emptyMap(), ) -@Serializable -enum class KnownDeviceTransportType { - @SerialName("bluetooth") - BLUETOOTH, - - @SerialName("usb") - USB, -} - -private fun TrezorTransportType.toKnownTransportType(): KnownDeviceTransportType = when (this) { - TrezorTransportType.BLUETOOTH -> KnownDeviceTransportType.BLUETOOTH - TrezorTransportType.USB -> KnownDeviceTransportType.USB +private fun TrezorTransportType.toTransportType(): TransportType = when (this) { + TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH + TrezorTransportType.USB -> TransportType.USB } -private fun KnownDeviceTransportType.toCoreTransportType(): TrezorTransportType = when (this) { - KnownDeviceTransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH - KnownDeviceTransportType.USB -> TrezorTransportType.USB +private fun TransportType.toCoreTransportType(): TrezorTransportType = when (this) { + TransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH + TransportType.USB -> TrezorTransportType.USB } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index a492771acf..6c53356f5f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -37,6 +37,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING import to.bitkit.models.msatFloorOf import to.bitkit.models.toAccountDerivationPath +import to.bitkit.models.toBalance import to.bitkit.models.toDerivationPath import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService @@ -66,6 +67,7 @@ class WalletRepo @Inject constructor( private val wipeWalletUseCase: WipeWalletUseCase, private val transferRepo: TransferRepo, private val activityRepo: ActivityRepo, + private val hwWalletRepo: HwWalletRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -84,6 +86,11 @@ class WalletRepo @Inject constructor( refreshBip21ForEvent(event) } } + repoScope.launch { + hwWalletRepo.wallets.collect { wallets -> + _balanceState.update { state -> state.copy(hardwareWallets = wallets.map { it.toBalance() }) } + } + } } fun loadFromCache() { diff --git a/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt b/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt new file mode 100644 index 0000000000..efd204761c --- /dev/null +++ b/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt @@ -0,0 +1,55 @@ +package to.bitkit.services + +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat + +/** + * Surfaces device link changes the per-connection callbacks miss: the phone's + * Bluetooth being switched off or back on, and a USB device being unplugged or + * plugged in. Transport-agnostic so any hardware-wallet transport (Trezor today, + * other vendors later) can plug its own handling into the same system events. + */ +class ConnectionStateReceiver( + private val onBluetoothOff: () -> Unit, + private val onBluetoothOn: () -> Unit, + private val onUsbDetached: (path: String) -> Unit, + private val onUsbAttached: (device: UsbDevice) -> Unit, +) : BroadcastReceiver() { + + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.action) { + BluetoothAdapter.ACTION_STATE_CHANGED -> { + when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) { + BluetoothAdapter.STATE_OFF, BluetoothAdapter.STATE_TURNING_OFF -> onBluetoothOff() + BluetoothAdapter.STATE_ON -> onBluetoothOn() + } + } + + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + device?.deviceName?.let(onUsbDetached) + } + + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + device?.let(onUsbAttached) + } + } + } + + fun register(context: Context) { + val filter = IntentFilter().apply { + addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + } + ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + } +} diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 1f57f085b2..09c556ed84 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -24,7 +24,6 @@ import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager -import android.hardware.usb.UsbRequest import android.os.Handler import android.os.Looper import android.os.ParcelUuid @@ -43,9 +42,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager +import to.bitkit.models.TransportType import to.bitkit.utils.Logger import java.io.File -import java.nio.ByteBuffer import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -121,6 +120,11 @@ class TrezorTransport @Inject constructor( private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) val externalDisconnect: SharedFlow = _externalDisconnect + private val _transportRestored = MutableSharedFlow(extraBufferCapacity = 1) + + /** Emits the transport that became available again: Bluetooth back on or a Trezor plugged in. */ + val transportRestored: SharedFlow = _transportRestored + @Volatile private var espMigrated = false @@ -159,6 +163,38 @@ class TrezorTransport @Inject constructor( bluetoothManager.adapter } + /** + * Feeds Bluetooth-off and USB-unplug events into the same [externalDisconnect] + * flow so the repo clears the connected device and the UI connection indicator + * reflects reality in real time. Bluetooth coming back on or a Trezor being + * plugged in emits [transportRestored] so the repo can silently reconnect. + */ + private val connectionStateReceiver = ConnectionStateReceiver( + onBluetoothOff = { + bleConnections.keys.toList().forEach { path -> + bleConnections[path]?.isConnected = false + emitExternalDisconnect(path) + } + }, + onBluetoothOn = { _transportRestored.tryEmit(TransportType.BLUETOOTH) }, + onUsbDetached = { path -> + if (path in usbConnections.keys) emitExternalDisconnect(path) + }, + onUsbAttached = { device -> + if (isTrezorDevice(device)) _transportRestored.tryEmit(TransportType.USB) + }, + ) + + private fun emitExternalDisconnect(path: String) { + if (!userInitiatedCloseSet.remove(path)) { + _externalDisconnect.tryEmit(path) + } + } + + init { + connectionStateReceiver.register(context) + } + private val usbConnections = ConcurrentHashMap() private val bleConnections = ConcurrentHashMap() @@ -665,39 +701,27 @@ class TrezorTransport @Inject constructor( error = "Device not open: $path", ) - val buffer = ByteBuffer.allocate(USB_CHUNK_SIZE) - val request = UsbRequest() - try { - if (!request.initialize(openDevice.connection, openDevice.readEndpoint)) { - return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "Failed to initialize USB read request", - ) - } - if (!request.queue(buffer)) { - return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "Failed to queue USB read request", - ) - } - openDevice.connection.requestWait(READ_TIMEOUT_MS.toLong()) - ?: return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "USB read timed out", - ) - - buffer.flip() - val bytesRead = buffer.remaining() - val data = ByteArray(bytesRead) - buffer.get(data) - Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") - } finally { - request.close() + // Synchronous transfer on purpose: the async UsbRequest API requires + // cancelling and reaping a timed-out request before closing it; closing + // with the URB still queued frees memory the kernel later writes into + // (native SIGSEGV in libusbhost once the device finally responds). + 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 = "USB read timed out", + ) } + + Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) + TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") @@ -710,29 +734,18 @@ class TrezorTransport @Inject constructor( val openDevice = usbConnections[path] ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") - val buffer = ByteBuffer.wrap(data) - val request = UsbRequest() - try { - if (!request.initialize(openDevice.connection, openDevice.writeEndpoint)) { - return TrezorTransportWriteResult( - success = false, - error = "Failed to initialize USB write request", - ) - } - if (!request.queue(buffer)) { - return TrezorTransportWriteResult( - success = false, - error = "Failed to queue USB write request", - ) - } - openDevice.connection.requestWait(WRITE_TIMEOUT_MS.toLong()) - ?: return TrezorTransportWriteResult(success = false, error = "USB write timed out") - - Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") - } finally { - request.close() + val bytesWritten = openDevice.connection.bulkTransfer( + openDevice.writeEndpoint, + data, + data.size, + WRITE_TIMEOUT_MS, + ) + if (bytesWritten != data.size) { + return TrezorTransportWriteResult(success = false, error = "USB write timed out") } + + Logger.debug("USB wrote '${data.size}' 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") diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index e054b9b657..b294583796 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -182,6 +182,7 @@ import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.DisablePinSheet import to.bitkit.ui.sheets.ForceTransferSheet import to.bitkit.ui.sheets.GiftSheet +import to.bitkit.ui.sheets.HardwareSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet @@ -454,6 +455,12 @@ fun ContentView( Sheet.ChangePin -> ChangePinSheet(appViewModel) Sheet.DisablePin -> DisablePinSheet(appViewModel) is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) + is Sheet.Hardware -> HardwareSheet( + sheet = sheet, + onDismiss = { appViewModel.hideSheet() }, + onSubmitPairingCode = appViewModel::submitPairingCode, + onCancelPairingCode = appViewModel::cancelPairingCode, + ) is Sheet.Widgets -> { WidgetsSheet( sheet = sheet, diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 41bfcae26c..ed8a743959 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -2,6 +2,7 @@ package to.bitkit.ui import android.app.NotificationManager import android.content.Intent +import android.hardware.usb.UsbManager import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -95,6 +96,7 @@ class MainActivity : FragmentActivity() { if (currentLaunchIntent == null || currentLaunchIntent != consumedLaunchIntent) { appViewModel.handleDeeplinkIntent(intent) } + handleUsbAttachIntent(intent) installSplashScreen() enableAppEdgeToEdge() @@ -210,6 +212,17 @@ class MainActivity : FragmentActivity() { super.onNewIntent(intent) setIntent(intent) appViewModel.handleDeeplinkIntent(intent) + handleUsbAttachIntent(intent) + } + + /** + * The OS delivers the USB attach event as an activity intent (via the app picker), + * not as a broadcast, so it is forwarded from here to trigger the silent reconnect. + */ + private fun handleUsbAttachIntent(intent: Intent) { + if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { + appViewModel.onUsbDeviceAttached() + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt index 765682f2c5..139402eaac 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay @@ -50,11 +51,14 @@ import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds const val KEY_DELETE = "delete" const val KEY_000 = "000" const val KEY_DECIMAL = "." private val defaultHeight = 300.dp +private const val FOCUS_RETRY_COUNT = 10 +private val FOCUS_RETRY_DELAY = 50.milliseconds private val idealButtonHeight = 75.dp private val minButtonHeight = 50.dp private const val ROWS = 4 @@ -78,7 +82,14 @@ fun NumberPad( onDeleteLongPress: (() -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(Unit) { + // Composing mid sheet/nav transition can drop the initial request, leaving + // hardware keyboard input dead; retry briefly until the focus node takes it. + repeat(FOCUS_RETRY_COUNT) { + if (runCatching { focusRequester.requestFocus() }.isSuccess) return@LaunchedEffect + delay(FOCUS_RETRY_DELAY) + } + } val safeAreaModifier = if (includeNavigationBarsPadding) { modifier.navigationBarsPadding() } else { diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index ffb8d1748b..4711d22daa 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -34,6 +34,7 @@ import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute +import to.bitkit.ui.sheets.HardwareRoute import to.bitkit.ui.sheets.PinRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.WidgetsRoute @@ -57,6 +58,7 @@ sealed interface Sheet { data object ChangePin : Sheet data object DisablePin : Sheet data class Backup(val route: BackupRoute = BackupRoute.ShowMnemonic) : Sheet + data class Hardware(val route: HardwareRoute = HardwareRoute.Intro) : Sheet data class Widgets(val route: WidgetsRoute = WidgetsRoute.Gallery) : Sheet data object ActivityDateRangeSelector : Sheet data object ActivityTagSelector : Sheet diff --git a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt index 656167485f..12afac6869 100644 --- a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt +++ b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt @@ -45,6 +45,7 @@ fun RowScope.WalletBalanceView( icon: Painter, modifier: Modifier = Modifier, currencies: CurrencyState = LocalCurrencies.current, + titleTrailing: @Composable RowScope.() -> Unit = {}, ) { val isPreview = LocalInspectionMode.current if (isPreview) { @@ -63,6 +64,7 @@ fun RowScope.WalletBalanceView( primaryDisplay = PrimaryDisplay.BITCOIN, displayUnit = BitcoinDisplayUnit.MODERN, hideBalance = false, + titleTrailing = titleTrailing, ) } @@ -83,6 +85,7 @@ fun RowScope.WalletBalanceView( primaryDisplay = primaryDisplay, displayUnit = displayUnit, hideBalance = hideBalance, + titleTrailing = titleTrailing, ) } @@ -95,16 +98,20 @@ private fun RowScope.Content( primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, hideBalance: Boolean, + titleTrailing: @Composable RowScope.() -> Unit = {}, ) { Column( modifier = Modifier .weight(1f) .then(modifier) ) { - Text13Up( - text = title, - color = Colors.White64, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text13Up( + text = title, + color = Colors.White64, + ) + titleTrailing() + } VerticalSpacer(8.dp) converted?.let { converted -> 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 index 3eb472eb3c..37958ed0fb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R +import to.bitkit.models.TransportType import to.bitkit.repositories.KnownDevice -import to.bitkit.repositories.KnownDeviceTransportType import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer @@ -98,8 +98,8 @@ internal fun KnownDeviceCard( Icon( painter = painterResource( when (device.transportType) { - KnownDeviceTransportType.BLUETOOTH -> R.drawable.ic_broadcast - KnownDeviceTransportType.USB -> R.drawable.ic_git_branch + TransportType.BLUETOOTH -> R.drawable.ic_broadcast + TransportType.USB -> R.drawable.ic_git_branch } ), contentDescription = null, @@ -120,8 +120,8 @@ internal fun KnownDeviceCard( ) { Caption( text = when (device.transportType) { - KnownDeviceTransportType.BLUETOOTH -> "Bluetooth" - KnownDeviceTransportType.USB -> "USB" + TransportType.BLUETOOTH -> "Bluetooth" + TransportType.USB -> "USB" }, color = Colors.White50, ) 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 deleted file mode 100644 index 161c1d06db..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ /dev/null @@ -1,115 +0,0 @@ -package to.bitkit.ui.screens.trezor - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -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.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Caption -import to.bitkit.ui.components.Footnote -import to.bitkit.ui.components.TertiaryButton -import to.bitkit.ui.components.Title -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.theme.AppThemeSurface -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, - shape = MaterialTheme.shapes.medium, - title = { - Title( - text = "Enter Pairing Code", - color = Colors.White, - ) - }, - text = { - Column { - Caption( - text = "Enter the 6-digit code shown on your Trezor device:", - color = Colors.White80, - ) - VerticalSpacer(16.dp) - OutlinedTextField( - value = code, - onValueChange = { newValue -> - if (newValue.all { it.isDigit() } && newValue.length <= 6) { - code = newValue - } - }, - placeholder = { - Footnote("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 = TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = 24.sp, - textAlign = TextAlign.Center, - letterSpacing = 8.sp, - ), - ) - } - }, - confirmButton = { - TertiaryButton( - text = "Submit", - onClick = { onSubmit(code) }, - enabled = code.length == 6, - size = ButtonSize.Small, - fullWidth = false, - ) - }, - dismissButton = { - TertiaryButton( - text = "Cancel", - onClick = onCancel, - size = ButtonSize.Small, - fullWidth = false, - ) - }, - ) -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewPairingCodeDialog() { - AppThemeSurface { - Box(Modifier.fillMaxSize()) { - PairingCodeDialog(onSubmit = {}, onCancel = {}) - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 2842e42005..b11758b18e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -19,9 +19,9 @@ import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice import to.bitkit.repositories.KnownDevice -import to.bitkit.repositories.KnownDeviceTransportType import to.bitkit.repositories.TrezorState import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -61,7 +61,7 @@ internal object TrezorPreviewData { id = "usb-1", name = "Trezor Safe 5", path = "/dev/usb/001", - transportType = KnownDeviceTransportType.USB, + transportType = TransportType.USB, label = "My Savings", model = "Safe 5", lastConnectedAt = 1_700_000_000_000L, @@ -71,7 +71,7 @@ internal object TrezorPreviewData { id = "ble-1", name = "Trezor Safe 7", path = "AA:BB:CC:DD:EE:FF", - transportType = KnownDeviceTransportType.BLUETOOTH, + transportType = TransportType.BLUETOOTH, label = "Daily Wallet", model = "Safe 7", lastConnectedAt = 1_700_000_000_000L, 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 9200f329b5..16beb3bbd7 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 @@ -97,7 +97,6 @@ private fun TrezorScreenContent( ) { val trezorState by viewModel.trezorState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val needsPairingCode by viewModel.needsPairingCode.collectAsStateWithLifecycle() val needsPinEntry by viewModel.needsPinEntry.collectAsStateWithLifecycle() val walletMode by viewModel.walletMode.collectAsStateWithLifecycle() @@ -115,13 +114,6 @@ private fun TrezorScreenContent( } } - if (needsPairingCode) { - PairingCodeDialog( - onSubmit = viewModel::submitPairingCode, - onCancel = viewModel::cancelPairingCode, - ) - } - if (needsPinEntry) { PinEntryDialog( onSubmit = viewModel::submitPin, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index bb2831a614..7a2e991dc8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -56,7 +56,6 @@ class TrezorViewModel @Inject constructor( private val watcherStartScope = CoroutineScope(SupervisorJob() + bgDispatcher) init { - trezorRepo.observeExternalDisconnects(viewModelScope) observeWatcherEvents() } @@ -122,13 +121,6 @@ class TrezorViewModel @Inject constructor( 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) - val needsPinEntry = trezorRepo.needsPinEntry .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -818,20 +810,6 @@ class TrezorViewModel @Inject constructor( 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() - } - fun submitPin(pin: String) { trezorRepo.submitPin(pin) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 1af5a07764..69374e6e4c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -101,6 +102,7 @@ import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.dto.FeeCondition @@ -110,15 +112,20 @@ import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair import to.bitkit.env.Env +import to.bitkit.ext.rawId import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem +import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion +import to.bitkit.models.Toast +import to.bitkit.models.TransportType import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.effectiveSize +import to.bitkit.models.toBalance import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel import to.bitkit.ui.LocalBalances @@ -128,6 +135,7 @@ import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.Headline24 import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PubkyImage @@ -171,6 +179,7 @@ import to.bitkit.ui.screens.widgets.price.PriceCardSmall import to.bitkit.ui.screens.widgets.weather.WeatherCard import to.bitkit.ui.screens.widgets.weather.WeatherCardSmall import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.PinRoute @@ -214,6 +223,7 @@ fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val hasSeenTransferIntro by settingsViewModel.hasSeenTransferIntro.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() @@ -278,6 +288,10 @@ fun HomeScreen( rootNavController.navigateTo(Routes.BuyIntro) } + Suggestion.HARDWARE -> { + appViewModel.showSheet(Sheet.Hardware()) + } + Suggestion.LIGHTNING -> { if (!hasSeenTransferIntro) { rootNavController.navigateToTransferIntro() @@ -367,6 +381,14 @@ fun HomeScreen( onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, + onClickHardwareWallet = { + scope.launch { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Hardware wallet overview not yet implemented.", + ) + } + }, onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -403,6 +425,7 @@ private fun Content( onNavigateToActivityItem: (String) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, + onClickHardwareWallet: () -> Unit = {}, onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, @@ -530,6 +553,7 @@ private fun Content( onNavigateToActivityItem = onNavigateToActivityItem, onNavigateToSavings = onNavigateToSavings, onNavigateToSpending = onNavigateToSpending, + onClickHardwareWallet = onClickHardwareWallet, ) 1 -> WidgetsPage( @@ -570,6 +594,7 @@ private fun WalletPage( onNavigateToActivityItem: (String) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, + onClickHardwareWallet: () -> Unit, ) { val heightStatusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val pullToRefreshState = rememberPullToRefreshState() @@ -602,7 +627,7 @@ private fun WalletPage( VerticalSpacer(16.dp) BalanceHeaderView( - sats = balances.totalSats.toLong(), + sats = balances.totalWithHardwareSats.toLong(), showEyeIcon = true, testTag = "TotalBalance", modifier = Modifier @@ -610,7 +635,13 @@ private fun WalletPage( .testTag("TotalBalance") ) VerticalSpacer(32.dp) - BalancesSection(balances, onNavigateToSavings, onNavigateToSpending) + BalancesSection( + balances = balances, + hardwareWallets = homeUiState.hardwareWallets, + onNavigateToSavings = onNavigateToSavings, + onNavigateToSpending = onNavigateToSpending, + onClickHardwareWallet = onClickHardwareWallet, + ) VerticalSpacer(32.dp) if (!homeUiState.showEmptyState) { @@ -639,10 +670,16 @@ private fun WalletPage( } } + val hardwareIds = remember(homeUiState.hardwareWallets) { + homeUiState.hardwareWallets + .flatMap { wallet -> wallet.activities.map { it.rawId() } } + .toImmutableSet() + } ActivityListSimple( items = latestActivities, onAllActivityClick = onNavigateToAllActivity, onActivityItemClick = onNavigateToActivityItem, + hardwareIds = hardwareIds, ) FillHeight() @@ -670,33 +707,97 @@ private fun WalletPage( @Composable private fun BalancesSection( balances: BalanceState, + hardwareWallets: ImmutableList, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, + onClickHardwareWallet: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - ) { - WalletBalanceView( - title = stringResource(R.string.wallet__savings__title), - sats = balances.totalOnchainSats.toLong(), - icon = painterResource(id = R.drawable.ic_btc_circle), + Column(modifier = Modifier.fillMaxWidth()) { + Row( modifier = Modifier - .clickableAlpha(onClick = onNavigateToSavings) - .padding(vertical = 4.dp) - .testTag("ActivitySavings") - ) - VerticalDivider(color = Colors.Gray4) - HorizontalSpacer(16.dp) - WalletBalanceView( - title = stringResource(R.string.wallet__spending__title), - sats = balances.totalLightningSats.toLong(), - icon = painterResource(id = R.drawable.ic_ln_circle), + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + WalletBalanceView( + title = stringResource(R.string.wallet__savings__title), + sats = balances.totalOnchainSats.toLong(), + icon = painterResource(id = R.drawable.ic_btc_circle), + modifier = Modifier + .clickableAlpha(onClick = onNavigateToSavings) + .padding(vertical = 4.dp) + .testTag("ActivitySavings") + ) + VerticalDivider(color = Colors.Gray4) + HorizontalSpacer(16.dp) + WalletBalanceView( + title = stringResource(R.string.wallet__spending__title), + sats = balances.totalLightningSats.toLong(), + icon = painterResource(id = R.drawable.ic_ln_circle), + modifier = Modifier + .clickableAlpha(onClick = onNavigateToSpending) + .padding(vertical = 4.dp) + .testTag("ActivitySpending") + ) + } + + HwDevices(wallets = hardwareWallets, onClick = onClickHardwareWallet) + } +} + +/** + * Hardware wallets flow into a 2-column grid below the Savings/Spending tiles: + * a second device fills the bottom-right column, additional devices wrap onto new rows. + */ +@Composable +private fun HwDevices( + wallets: ImmutableList, + onClick: () -> Unit, +) { + wallets.chunked(2).forEach { rowWallets -> + VerticalSpacer(16.dp) + Row( modifier = Modifier - .clickableAlpha(onClick = onNavigateToSpending) - .padding(vertical = 4.dp) - .testTag("ActivitySpending") + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + HwDeviceCell(wallet = rowWallets[0], onClick = onClick) + VerticalDivider(color = Colors.Gray4) + HorizontalSpacer(16.dp) + val second = rowWallets.getOrNull(1) + if (second != null) { + HwDeviceCell(wallet = second, onClick = onClick) + } else { + FillWidth() + } + } + } +} + +@Composable +private fun RowScope.HwDeviceCell( + wallet: HwWallet, + onClick: () -> Unit, +) { + WalletBalanceView( + title = wallet.name, + sats = wallet.balanceSats.toLong(), + icon = painterResource(id = R.drawable.ic_btc_circle_blue), + modifier = Modifier + .clickableAlpha(onClick = onClick) + .padding(vertical = 4.dp) + .testTag("ActivityHardware") + ) { + HorizontalSpacer(4.dp) + Icon( + painter = painterResource( + id = when (wallet.transportType) { + TransportType.BLUETOOTH -> R.drawable.ic_bluetooth_connected + TransportType.USB -> R.drawable.ic_usb_connected + } + ), + contentDescription = null, + tint = if (wallet.isConnected) Colors.Green else Colors.Gray1, + modifier = Modifier.size(16.dp) ) } } @@ -1387,6 +1488,33 @@ private val previewWeather = WeatherModel( private val previewLatestActivities = previewActivityItems.take(3).toImmutableList() private val previewBanners = ActivityBannerType.entries.map { BannerItem(type = it, title = "") }.toImmutableList() private val previewSuggestions = Suggestion.entries.take(4).toImmutableList() +private val previewHardwareWalletBt = HwWallet( + id = "trezor-1", + name = "Trezor Safe 5", + model = "Safe 5", + transportType = TransportType.BLUETOOTH, + isConnected = true, + balanceSats = 10_562_411uL, + activities = persistentListOf(), +) +private val previewHardwareWalletUsb = HwWallet( + id = "trezor-2", + name = "Trezor Model T", + model = "Model T", + transportType = TransportType.USB, + isConnected = false, + balanceSats = 2_735_180uL, + activities = persistentListOf(), +) +private val previewHardwareWalletThird = HwWallet( + id = "trezor-3", + name = "Trezor Safe 3", + model = "Safe 3", + transportType = TransportType.BLUETOOTH, + isConnected = true, + balanceSats = 500_000uL, + activities = persistentListOf(), +) @Preview(showSystemUi = true) @Composable @@ -1407,6 +1535,78 @@ private fun PreviewWithActivity() { } } +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithHardwareWallet() { + AppThemeSurface { + Box { + Content( + isRefreshing = false, + homeUiState = HomeUiState( + hardwareWallets = persistentListOf(previewHardwareWalletBt), + ), + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + latestActivities = previewLatestActivities, + balances = previewBalances.copy(hardwareWallets = listOf(previewHardwareWalletBt.toBalance())), + ) + TabBar() + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithTwoHardwareWallets() { + AppThemeSurface { + Box { + Content( + isRefreshing = false, + homeUiState = HomeUiState( + hardwareWallets = persistentListOf(previewHardwareWalletBt, previewHardwareWalletUsb), + ), + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + latestActivities = previewLatestActivities, + balances = previewBalances.copy( + hardwareWallets = listOf( + previewHardwareWalletBt.toBalance(), + previewHardwareWalletUsb.toBalance(), + ) + ), + ) + TabBar() + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithThreeHardwareWallets() { + AppThemeSurface { + Box { + Content( + isRefreshing = false, + homeUiState = HomeUiState( + hardwareWallets = persistentListOf( + previewHardwareWalletBt, + previewHardwareWalletUsb, + previewHardwareWalletThird, + ), + ), + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + latestActivities = previewLatestActivities, + balances = previewBalances.copy( + hardwareWallets = listOf( + previewHardwareWalletBt.toBalance(), + previewHardwareWalletUsb.toBalance(), + previewHardwareWalletThird.toBalance(), + ) + ), + ) + TabBar() + } + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 6efbef038d..0156048dbd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.data.dto.price.PriceDTO import to.bitkit.models.BannerItem +import to.bitkit.models.HwWallet import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition @@ -19,6 +20,7 @@ import to.bitkit.ui.screens.widgets.blocks.WeatherModel @Stable data class HomeUiState( val suggestions: ImmutableList = persistentListOf(), + val hardwareWallets: ImmutableList = persistentListOf(), val banners: ImmutableList = persistentListOf(), val showWidgets: Boolean = false, val widgetsWithPosition: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index b06516e4be..d0a4aafa68 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.models.widget.toArticleModel import to.bitkit.models.widget.toBlockModel import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo @@ -51,6 +52,7 @@ class HomeViewModel @Inject constructor( private val transferRepo: TransferRepo, private val pubkyRepo: PubkyRepo, private val activityRepo: ActivityRepo, + private val hwWalletRepo: HwWalletRepo, ) : ViewModel() { companion object { @@ -120,6 +122,12 @@ class HomeViewModel @Inject constructor( } } + viewModelScope.launch { + hwWalletRepo.wallets.collect { wallets -> + _uiState.update { it.copy(hardwareWallets = wallets) } + } + } + @OptIn(ExperimentalCoroutinesApi::class) val hasActivityFlow = activityRepo.activitiesChanged.mapLatest { activityRepo.getActivities(limit = 1u).getOrNull()?.isNotEmpty() == true @@ -312,12 +320,14 @@ class HomeViewModel @Inject constructor( settingsStore.data, transferRepo.activeTransfers, pubkyRepo.isAuthenticated, - ) { balanceState, settings, transfers, profileAuthenticated -> + hwWalletRepo.wallets, + ) { balanceState, settings, transfers, profileAuthenticated, hardwareWallets -> + val hasHardwareWallet = hardwareWallets.isNotEmpty() val baseSuggestions = when { balanceState.totalLightningSats > 0uL -> - spendingSuggestions(settings, profileAuthenticated) + spendingSuggestions(settings, profileAuthenticated, hasHardwareWallet) balanceState.totalOnchainSats > 0uL -> - savingsOnlySuggestions(settings, transfers, profileAuthenticated) + savingsOnlySuggestions(settings, transfers, profileAuthenticated, hasHardwareWallet) else -> emptyWalletSuggestions(settings, transfers, profileAuthenticated) } val dismissedList = settings.dismissedSuggestions.mapNotNull { it.toSuggestionOrNull() } @@ -329,9 +339,11 @@ class HomeViewModel @Inject constructor( private fun spendingSuggestions( settings: SettingsData, profileAuthenticated: Boolean, + hasHardwareWallet: Boolean, ) = listOfNotNull( Suggestion.QUICK_PAY.takeIf { !settings.isQuickPayEnabled }, Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted }, + Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, Suggestion.SHOP, Suggestion.PROFILE.takeIf { !profileAuthenticated }, Suggestion.SUPPORT, @@ -343,12 +355,14 @@ class HomeViewModel @Inject constructor( settings: SettingsData, transfers: List, profileAuthenticated: Boolean, + hasHardwareWallet: Boolean, ) = listOfNotNull( Suggestion.BACK_UP.takeIf { !settings.backupVerified }, Suggestion.SECURE.takeIf { !settings.isPinEnabled }, Suggestion.LIGHTNING.takeIf { transfers.all { it.type != TransferType.TO_SPENDING } }, + Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, Suggestion.SUPPORT, Suggestion.PROFILE.takeIf { !profileAuthenticated }, Suggestion.INVITE, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index c8b6223a58..a51179de39 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -246,6 +246,7 @@ fun ActivityDetailScreen( onChannelClick = onChannelClick, detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, + isHardware = uiState.isHardwareActivity, showContactActions = isPaykitEnabled, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> @@ -328,6 +329,7 @@ private fun ActivityDetailContent( onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, + isHardware: Boolean = false, showContactActions: Boolean = true, boostTxDoesExist: ImmutableMap = persistentMapOf(), onCopy: (String) -> Unit, @@ -403,6 +405,7 @@ private fun ActivityDetailContent( activity = item, size = 48.dp, isCpfpChild = isCpfpChild, + isHardware = isHardware, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index 457bc3b891..e05492d000 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -44,6 +44,7 @@ fun AllActivityScreen( ) { val app = appViewModel ?: return val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() + val hardwareIds by viewModel.hardwareIds.collectAsStateWithLifecycle() val searchText by viewModel.searchText.collectAsStateWithLifecycle() val selectedTags by viewModel.selectedTags.collectAsStateWithLifecycle() @@ -55,6 +56,7 @@ fun AllActivityScreen( AllActivityScreenContent( filteredActivities = filteredActivities, + hardwareIds = hardwareIds, searchText = searchText, onSearchTextChange = { viewModel.setSearchText(it) }, hasTagFilter = selectedTags.isNotEmpty(), @@ -76,6 +78,7 @@ fun AllActivityScreen( private fun AllActivityScreenContent( filteredActivities: ImmutableList?, searchText: String, + hardwareIds: ImmutableSet = persistentSetOf(), onSearchTextChange: (String) -> Unit, hasTagFilter: Boolean, selectedTags: ImmutableSet, @@ -129,6 +132,7 @@ private fun AllActivityScreenContent( items = filteredActivities, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = onEmptyActivityRowClick, + hardwareIds = hardwareIds, listState = listState, contentPadding = PaddingValues(top = topPadding + 16.dp), modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 8522ca32ef..1127c8c768 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -41,6 +41,7 @@ fun ActivityIcon( modifier: Modifier = Modifier, size: Dp = 32.dp, isCpfpChild: Boolean = false, + isHardware: Boolean = false, contact: PubkyProfile? = null, ) { val isLightning = activity is Activity.Lightning @@ -67,7 +68,7 @@ fun ActivityIcon( modifier = modifier ) isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) - else -> ActivityIconOnchain(activity, arrowIcon, size, modifier) + else -> ActivityIconOnchain(activity, arrowIcon, size, isHardware, modifier) } } @@ -76,12 +77,21 @@ private fun ActivityIconOnchain( activity: Activity, arrowIcon: Painter, size: Dp, + isHardware: Boolean, modifier: Modifier = Modifier, ) { val isTransfer = activity.isTransfer() val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED - val transferIconColor = if (isTransferFromSpending) Colors.Purple else Colors.Brand - val transferBackgroundColor = if (isTransferFromSpending) Colors.Purple16 else Colors.Brand16 + val (iconColor, backgroundColor) = when { + isHardware -> Colors.Blue to Colors.Blue16 + isTransferFromSpending -> Colors.Purple to Colors.Purple16 + else -> Colors.Brand to Colors.Brand16 + } + val tag = when { + isHardware -> "HardwareActivityIcon" + isTransfer -> "TransferIcon" + else -> "ActivityIcon" + } CircularIcon( icon = when { @@ -89,10 +99,10 @@ private fun ActivityIconOnchain( isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, - iconColor = if (isTransfer) transferIconColor else Colors.Brand, - backgroundColor = if (isTransfer) transferBackgroundColor else Colors.Brand16, + iconColor = iconColor, + backgroundColor = backgroundColor, size = size, - modifier = modifier.testTag(if (isTransfer) "TransferIcon" else "ActivityIcon"), + modifier = modifier.testTag(tag), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index d72ed42008..69e786bba6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.rawId import to.bitkit.ui.activityListViewModel @@ -54,6 +56,7 @@ fun ActivityListGrouped( contentPadding: PaddingValues = PaddingValues(top = 20.dp), activityTestTagPrefix: String = "Activity", showContactAvatar: Boolean = true, + hardwareIds: ImmutableSet = persistentSetOf(), titleProvider: @Composable (Activity) -> String? = { null }, ) { val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { @@ -117,6 +120,7 @@ fun ActivityListGrouped( onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", title = titleProvider(item) ?: contactActivityTitle(item, contacts), + isHardware = item.rawId() in hardwareIds, contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 74b38d8aa0..5af0ba3173 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -17,8 +17,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R +import to.bitkit.ext.rawId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer @@ -30,6 +33,7 @@ fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, onActivityItemClick: (String) -> Unit, + hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -47,6 +51,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), + isHardware = item.rawId() in hardwareIds, contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 5fbfb9025f..a26a324811 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -68,6 +68,7 @@ fun ActivityRow( onClick: (String) -> Unit, testTag: String, title: String? = null, + isHardware: Boolean = false, contact: PubkyProfile? = null, ) { val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { @@ -115,7 +116,13 @@ fun ActivityRow( .padding(16.dp) .testTag(testTag) ) { - ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild, contact = resolvedContact) + ActivityIcon( + activity = item, + size = 40.dp, + isCpfpChild = isCpfpChild, + isHardware = isHardware, + contact = resolvedContact, + ) HorizontalSpacer(16.dp) Column( verticalArrangement = Arrangement.spacedBy(2.dp), diff --git a/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt b/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt index 2813848587..1fdb18c5ac 100644 --- a/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import java.time.LocalDate import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.env.Env @@ -65,6 +64,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import java.time.LocalDate private const val DEV_MODE_TAP_THRESHOLD = 5 private const val COPYRIGHT_YEAR_PLACEHOLDER = "{year}" diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt index 8309cc101e..eb37d5ad13 100644 --- a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt +++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt @@ -14,6 +14,8 @@ import to.bitkit.ui.components.SheetSize import to.bitkit.ui.theme.Insets import to.bitkit.ui.theme.TopBarHeight +private val TOOLBAR_CLEARANCE = 16.dp + fun Modifier.sheetHeight( size: SheetSize = SheetSize.LARGE, isModal: Boolean = false, @@ -21,7 +23,9 @@ fun Modifier.sheetHeight( // Bottom safe-area belongs in sheet content padding; including it here moves // non-modal sheet tops down on devices with larger navigation-bar insets. val modalBottomPadding = if (isModal) Insets.Bottom + Insets.Bottom else 0.dp - val topPadding = Insets.Top + modalBottomPadding + TopBarHeight - 6.dp + // Reserve the full home toolbar block plus clearance so sheets open below it, + // keeping the toolbar visible instead of overlapping its bottom edge. + val topPadding = Insets.Top + modalBottomPadding + TopBarHeight + TOOLBAR_CLEARANCE val height = when (size) { SheetSize.LARGE -> screenHeight(minus = topPadding) // topbar visible diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt new file mode 100644 index 0000000000..32096d54ce --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -0,0 +1,260 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.composableWithDefaultTransitions +import to.bitkit.ui.utils.withAccent + +/** + * Entry point for the hardware-wallet connect flow opened from the home suggestion + * card, and host of the Pair Device screen shown app-wide when the device asks for + * its one-time pairing code. The remaining connect steps land in the dedicated + * connect-flow subtask, which enables the Continue button. + */ +@Composable +fun HardwareSheet( + sheet: Sheet.Hardware, + onDismiss: () -> Unit, + onSubmitPairingCode: (String) -> Unit, + onCancelPairingCode: () -> Unit, +) { + val navController = rememberNavController() + + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.LARGE) + .gradientBackground() + .testTag("hardware_sheet") + ) { + NavHost( + navController = navController, + startDestination = sheet.route, + ) { + composableWithDefaultTransitions { + HardwareIntro(onClose = onDismiss) + } + composableWithDefaultTransitions { + HardwarePairing( + onSubmit = onSubmitPairingCode, + onCancel = onCancelPairingCode, + ) + } + } + } +} + +@Composable +private fun HardwareIntro(onClose: () -> Unit) { + Column(modifier = Modifier.fillMaxSize()) { + SheetTopBar(titleText = stringResource(R.string.hardware__intro_title)) + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + val imageSize = maxWidth * INTRO_IMAGE_SIZE_RATIO + val staggerY = maxWidth * INTRO_IMAGE_STAGGER_RATIO + Image( + painter = painterResource(R.drawable.trezor), + contentDescription = null, + modifier = Modifier + .size(imageSize) + .align(Alignment.CenterStart) + .offset(x = -maxWidth * INTRO_TREZOR_BLEED_RATIO, y = staggerY) + ) + Image( + painter = painterResource(R.drawable.ledger), + contentDescription = null, + modifier = Modifier + .size(imageSize) + .align(Alignment.CenterEnd) + .offset(x = maxWidth * INTRO_LEDGER_BLEED_RATIO, y = -staggerY) + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .navigationBarsPadding() + ) { + Display(stringResource(R.string.hardware__intro_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__intro_text), color = Colors.White64) + VerticalSpacer(32.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onClose, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = {}, + enabled = false, + modifier = Modifier.weight(1f) + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun HardwarePairing( + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var code by remember { mutableStateOf("") } + var submitted by remember { mutableStateOf(false) } + + // Dismissing the sheet without submitting (e.g. swipe down) cancels the pending + // pairing request so the connect attempt does not hang until its timeout. + DisposableEffect(Unit) { + onDispose { if (!submitted) onCancel() } + } + + Column(modifier = Modifier.fillMaxSize()) { + SheetTopBar(titleText = stringResource(R.string.hardware__pairing_title)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 32.dp) + ) { + BodyM(stringResource(R.string.hardware__pairing_text), color = Colors.White64) + FillHeight() + // Fixed-width cells so digits replace dots without the row shifting. + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(PAIRING_CODE_LENGTH) { index -> + val digit = code.getOrNull(index)?.toString() + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.width(PAIRING_CELL_WIDTH) + ) { + Display( + text = digit ?: "•", + color = if (digit != null) Colors.White else Colors.White32, + ) + } + } + } + FillHeight() + } + NumberPad( + onPress = { key -> + when { + key == KEY_DELETE -> code = code.dropLast(1) + code.length < PAIRING_CODE_LENGTH -> { + code += key + if (code.length == PAIRING_CODE_LENGTH) { + submitted = true + onSubmit(code) + } + } + } + }, + includeNavigationBarsPadding = true, + ) + } +} + +sealed interface HardwareRoute { + @Serializable + data object Intro : HardwareRoute + + @Serializable + data object PairingCode : HardwareRoute +} + +private const val PAIRING_CODE_LENGTH = 6 +private val PAIRING_CELL_WIDTH = 32.dp + +// Proportions taken from the 375dp-wide Figma frame: 256dp visuals bleeding +// 84dp off the left edge and 53dp off the right, staggered by 12dp vertically. +private const val INTRO_IMAGE_SIZE_RATIO = 256f / 375f +private const val INTRO_TREZOR_BLEED_RATIO = 84f / 375f +private const val INTRO_LEDGER_BLEED_RATIO = 53f / 375f +private const val INTRO_IMAGE_STAGGER_RATIO = 12f / 375f + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.LARGE, isModal = true) + .gradientBackground() + ) { + HardwareIntro(onClose = {}) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PairingPreview() { + AppThemeSurface { + BottomSheetPreview { + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.LARGE, isModal = true) + .gradientBackground() + ) { + HardwarePairing(onSubmit = {}, onCancel = {}) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt index 167db8a0dc..58a6d4917f 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt @@ -41,6 +41,7 @@ object Colors { val White64 = Color.White.copy(alpha = 0.64f) val White80 = Color.White.copy(alpha = 0.80f) + val Blue16 = Blue.copy(alpha = 0.16f) val Blue24 = Blue.copy(alpha = 0.24f) val Brand08 = Brand.copy(alpha = 0.08f) val Brand16 = Brand.copy(alpha = 0.16f) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 3822e73dd0..e68715ec31 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -14,6 +14,8 @@ import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.BalanceState import to.bitkit.models.TransferType import to.bitkit.models.safe +import to.bitkit.models.toBalance +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger @@ -27,6 +29,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val lightningRepo: LightningRepo, private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, + private val hwWalletRepo: HwWalletRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { runCatching { @@ -54,6 +57,7 @@ class DeriveBalanceStateUseCase @Inject constructor( maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), balanceInTransferToSpending = toSpendingAmount, + hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) val height = lightningRepo.lightningState.value.block()?.height diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 02caf41a74..e351e98263 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -27,6 +27,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -38,6 +39,7 @@ class ActivityDetailViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, + private val hwWalletRepo: HwWalletRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() @@ -66,13 +68,7 @@ class ActivityDetailViewModel @Inject constructor( loadTags() observeActivityChanges(activityId) } else { - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Error( - context.getString(R.string.wallet__activity_error_not_found) - ) - ) - } + loadHwWalletActivity(activityId) } } .onFailure { e -> @@ -91,11 +87,33 @@ class ActivityDetailViewModel @Inject constructor( fun clearActivityState() { observeJob?.cancel() observeJob = null - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial) } + _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } activity = null _tags.update { persistentListOf() } } + /** + * Watch-only hardware-wallet activities live in [HwWalletRepo], not the activity + * database, so tags and change observation don't apply to them. + */ + private fun loadHwWalletActivity(activityId: String) { + val hwActivity = hwWalletRepo.activities.value.find { it.rawId() == activityId } + if (hwActivity != null) { + activity = hwActivity + _uiState.update { + it.copy(activityLoadState = ActivityLoadState.Success(hwActivity), isHardwareActivity = true) + } + } else { + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Error( + context.getString(R.string.wallet__activity_error_not_found) + ) + ) + } + } + } + private fun observeActivityChanges(activityId: String) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { @@ -245,5 +263,6 @@ class ActivityDetailViewModel @Inject constructor( data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, + val isHardwareActivity: Boolean = false, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 24ca5fb22e..fe1c86e9de 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -29,9 +29,13 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer +import to.bitkit.ext.rawId +import to.bitkit.ext.timestamp +import to.bitkit.ext.txType import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -42,6 +46,7 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, + private val hwWalletRepo: HwWalletRepo, pubkyRepo: PubkyRepo, settingsStore: SettingsStore, ) : ViewModel() { @@ -55,7 +60,22 @@ class ActivityListViewModel @Inject constructor( val onchainActivities = _onchainActivities.asStateFlow() private val _latestActivities = MutableStateFlow?>(null) - val latestActivities = _latestActivities.asStateFlow() + + // Merge the device's watch-only hardware-wallet activity into the home list, + // newest first, capped at the same limit as the on-chain/lightning list. + val latestActivities: StateFlow?> = combine( + _latestActivities, + hwWalletRepo.activities, + ) { localActivities, hardwareActivities -> + if (localActivities == null && hardwareActivities.isEmpty()) { + null + } else { + (localActivities.orEmpty() + hardwareActivities) + .sortedByDescending { it.timestamp() } + .take(SIZE_LATEST) + .toImmutableList() + } + }.stateInScope(null) val contacts: StateFlow> = combine( @@ -68,6 +88,10 @@ class ActivityListViewModel @Inject constructor( val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) + val hardwareIds: StateFlow> = hwWalletRepo.activities + .map { activities -> activities.map { it.rawId() }.toImmutableSet() } + .stateInScope(persistentSetOf()) + private val _filters = MutableStateFlow(ActivityFilters()) // individual filters for UI @@ -110,13 +134,42 @@ class ActivityListViewModel @Inject constructor( _filters.map { it.searchText }.debounce(300), _filters.map { it.copy(searchText = "") }, activityRepo.activitiesChanged, - ) { debouncedSearch, filtersWithoutSearch, _ -> - fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) + hwWalletRepo.activities, + ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities -> + val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) + fetchFilteredActivities(filters)?.let { activities -> + (activities + hardwareActivities.filteredWith(filters)).sortedByDescending { it.timestamp() } + } }.collect { activities -> _filteredActivities.update { activities?.toImmutableList() } } } + /** + * Watch-only hardware-wallet activities live outside the activity database, so the + * list filters are applied to them here. They carry no tags and are never transfers. + */ + private fun List.filteredWith(filters: ActivityFilters): List { + if (filters.tags.isNotEmpty() || filters.tab == ActivityTab.OTHER) return emptyList() + + val minTimestamp = filters.startDate?.let { (it / 1000).toULong() } + val maxTimestamp = filters.endDate?.let { (it / 1000).toULong() } + + return filter { activity -> + val matchesTab = when (filters.tab) { + ActivityTab.SENT -> activity.txType() == PaymentType.SENT + ActivityTab.RECEIVED -> activity.txType() == PaymentType.RECEIVED + else -> true + } + val matchesSearch = filters.searchText.isEmpty() || + activity.rawId().contains(filters.searchText, ignoreCase = true) + val timestamp = activity.timestamp() + val matchesDate = (minTimestamp == null || timestamp >= minTimestamp) && + (maxTimestamp == null || timestamp <= maxTimestamp) + matchesTab && matchesSearch && matchesDate + } + } + private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 52c10c5235..b0f01754f3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -110,6 +110,7 @@ import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.TransportType import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue @@ -123,6 +124,7 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification @@ -143,6 +145,7 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.sheets.HardwareRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.usecases.FormatMoneyValue @@ -181,6 +184,7 @@ class AppViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, private val walletRepo: WalletRepo, + private val hwWalletRepo: HwWalletRepo, private val backupRepo: BackupRepo, private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, @@ -316,6 +320,29 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.updateGeoBlockState() } + viewModelScope.launch { + hwWalletRepo.receivedTxs.collect { tx -> + showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = tx.txid, + sats = tx.sats.toLong(), + ), + ) + } + } + viewModelScope.launch { + hwWalletRepo.needsPairingCode.collect { needsCode -> + if (needsCode) { + showPairingCodeSheet() + } else { + _currentSheet.update { sheet -> + if (sheet is Sheet.Hardware && sheet.route == HardwareRoute.PairingCode) null else sheet + } + } + } + } viewModelScope.launch { widgetsRepo.refreshEnabledWidgets() } @@ -3032,6 +3059,30 @@ class AppViewModel @Inject constructor( } } + fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored(TransportType.USB) + + fun submitPairingCode(code: String) = hwWalletRepo.submitPairingCode(code) + + fun cancelPairingCode() = hwWalletRepo.cancelPairingCode() + + /** + * The device asks for its one-time pairing code mid-connect, which can happen on + * any screen via silent reconnects, so the sheet is shown app-wide. High-priority + * sheets are not interrupted: reconnect retries re-raise the request shortly after. + */ + private fun showPairingCodeSheet() { + val current = _currentSheet.value + val isHighPrioritySheetShowing = current is Sheet.Gift || + current is Sheet.Send || + current is Sheet.BTCPayConnection || + current is Sheet.LnurlAuth || + current is Sheet.Pin || + current is Sheet.PubkyAuth + if (!isHighPrioritySheetShowing) { + showSheet(Sheet.Hardware(route = HardwareRoute.PairingCode)) + } + } + fun clearPendingPubkyImport() { viewModelScope.launch { pubkyRepo.clearPendingImport() diff --git a/app/src/main/res/drawable-nodpi/ledger.webp b/app/src/main/res/drawable-nodpi/ledger.webp new file mode 100644 index 0000000000..304b85c788 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ledger.webp differ diff --git a/app/src/main/res/drawable-nodpi/trezor.webp b/app/src/main/res/drawable-nodpi/trezor.webp new file mode 100644 index 0000000000..e64e3936fe Binary files /dev/null and b/app/src/main/res/drawable-nodpi/trezor.webp differ diff --git a/app/src/main/res/drawable/ic_bluetooth_connected.xml b/app/src/main/res/drawable/ic_bluetooth_connected.xml new file mode 100644 index 0000000000..720bfd6c8b --- /dev/null +++ b/app/src/main/res/drawable/ic_bluetooth_connected.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btc_circle_blue.xml b/app/src/main/res/drawable/ic_btc_circle_blue.xml new file mode 100644 index 0000000000..e2017b6f38 --- /dev/null +++ b/app/src/main/res/drawable/ic_btc_circle_blue.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_usb_connected.xml b/app/src/main/res/drawable/ic_usb_connected.xml new file mode 100644 index 0000000000..3121cc7b3c --- /dev/null +++ b/app/src/main/res/drawable/ic_usb_connected.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ba0b7a597..54dceaafef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,8 @@ Back up Buy some bitcoin Buy + Connect device + Hardware Share Bitkit Invite Instant payments @@ -161,6 +163,11 @@ ±1-2 hours ±1h Slow + Add your <accent>hardware wallet</accent> + Connect your hardware device to watch or manage your long-term funds. + Hardware Wallet + Enter the 6-digit code shown on your Trezor device. + Pair Device Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. Funds\n<accent>availability</accent> Balance diff --git a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt new file mode 100644 index 0000000000..808b3b70a8 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt @@ -0,0 +1,49 @@ +package to.bitkit.models + +import org.junit.Test +import kotlin.test.assertEquals + +class BalanceStateTest { + + @Test + fun `totalSats sums onchain and lightning`() { + val state = BalanceState(totalOnchainSats = 100uL, totalLightningSats = 50uL) + assertEquals(150uL, state.totalSats) + } + + @Test + fun `totalHardwareSats sums all hardware wallet balances`() { + val state = BalanceState( + hardwareWallets = listOf( + HwWalletBalance(id = "dev1", sats = 25uL), + HwWalletBalance(id = "dev2", sats = 75uL), + ), + ) + assertEquals(100uL, state.totalHardwareSats) + } + + @Test + fun `totalWithHardwareSats adds the hardware balance on top of the total`() { + val state = BalanceState( + totalOnchainSats = 100uL, + totalLightningSats = 50uL, + hardwareWallets = listOf(HwWalletBalance(id = "dev1", sats = 25uL)), + ) + assertEquals(175uL, state.totalWithHardwareSats) + } + + @Test + fun `totalWithHardwareSats equals totalSats when there is no hardware balance`() { + val state = BalanceState(totalOnchainSats = 100uL, totalLightningSats = 50uL) + assertEquals(state.totalSats, state.totalWithHardwareSats) + } + + @Test + fun `totalWithHardwareSats saturates instead of overflowing`() { + val state = BalanceState( + totalLightningSats = ULong.MAX_VALUE, + hardwareWallets = listOf(HwWalletBalance(id = "dev1", sats = 10uL)), + ) + assertEquals(ULong.MAX_VALUE, state.totalWithHardwareSats) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 9a33118537..87fb228927 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -20,6 +20,7 @@ import to.bitkit.ext.create import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -30,6 +31,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() + private val hwWalletRepo = mock() companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -42,6 +44,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.wallet__activity_error_load_failed)).thenReturn("Failed to load activity") whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) + whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) sut = ActivityDetailViewModel( context = context, @@ -49,9 +52,46 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, + hwWalletRepo = hwWalletRepo, ) } + @Test + fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { + val hwActivity = Activity.Onchain( + OnchainActivity.create( + id = ACTIVITY_ID, + txType = PaymentType.RECEIVED, + txId = ACTIVITY_ID, + value = 100_000uL, + fee = 0uL, + address = "", + timestamp = 1_700_000_000uL, + confirmed = true, + ) + ) + whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) + + sut.loadActivity(ACTIVITY_ID) + + val state = sut.uiState.value + val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success + assertEquals(hwActivity, loadState.activity) + assertTrue(state.isHardwareActivity) + } + + @Test + fun `loadActivity reports not found when missing from database and hardware wallets`() = test { + whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + + sut.loadActivity(ACTIVITY_ID) + + val state = sut.uiState.value + assertTrue(state.activityLoadState is ActivityDetailViewModel.ActivityLoadState.Error) + assertFalse(state.isHardwareActivity) + } + @Test fun `findOrderForTransfer returns null when both channelId and txId are null`() = test { val result = sut.findOrderForTransfer(null, null) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt new file mode 100644 index 0000000000..ac364b1952 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -0,0 +1,390 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.TxDirection +import com.synonym.bitkitcore.WalletBalance +import com.synonym.bitkitcore.WatcherEvent +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.HwWalletData +import to.bitkit.data.HwWalletStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.env.Env +import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.TransportType +import to.bitkit.models.toCoreNetwork +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class HwWalletRepoTest : BaseUnitTest() { + + private val trezorRepo = mock() + private val hwWalletStore = mock() + private val settingsStore = mock() + private val clock = Clock.System + + private lateinit var storeData: MutableStateFlow + private lateinit var settingsData: MutableStateFlow + private lateinit var trezorState: MutableStateFlow + private lateinit var watcherEvents: MutableSharedFlow> + + private val device = KnownDevice( + id = "dev1", + name = null, + path = "ble:AA:BB", + transportType = TransportType.BLUETOOTH, + label = "Trezor", + model = "Safe 5", + lastConnectedAt = 0L, + ) + + @Before + fun setUp() { + storeData = MutableStateFlow(HwWalletData(knownDevices = listOf(device))) + settingsData = MutableStateFlow(SettingsData()) + trezorState = MutableStateFlow(TrezorState()) + watcherEvents = MutableSharedFlow(extraBufferCapacity = 8) + whenever(hwWalletStore.data).thenReturn(storeData) + whenever(settingsStore.data).thenReturn(settingsData) + whenever(trezorRepo.state).thenReturn(trezorState) + whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) + } + + private fun createRepo() = HwWalletRepo(trezorRepo, hwWalletStore, settingsStore, clock, testDispatcher) + + @Test + fun `lists a known device with zero balance before any watcher event`() = test { + val sut = createRepo() + + val wallet = sut.wallets.value.single() + assertEquals("dev1", wallet.id) + assertEquals("Trezor", wallet.name) + assertEquals(0uL, wallet.balanceSats) + assertEquals(0uL, sut.totalSats.value) + } + + @Test + fun `uses vendor-prefixed model as name when device label is missing`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device.copy(label = null, model = "Safe 7"))) + + val sut = createRepo() + + assertEquals("Trezor Safe 7", sut.wallets.value.single().name) + } + + @Test + fun `uses vendor-prefixed model as name when device label is the factory default`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device.copy(label = "Safe 7", model = "Safe 7"))) + + val sut = createRepo() + + assertEquals("Trezor Safe 7", sut.wallets.value.single().name) + } + + @Test + fun `transactions changed event sets device balance and maps activity`() = test { + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 10_562_411uL), + transactions = listOf(receivedTransaction(amount = 10_562_411uL)), + txCount = 1u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + val wallet = sut.wallets.value.single() + assertEquals(10_562_411uL, wallet.balanceSats) + assertEquals(10_562_411uL, sut.totalSats.value) + assertEquals(1, wallet.activities.size) + assertEquals(1, sut.activities.value.size) + assertEquals(Activity.Onchain::class, wallet.activities.single()::class) + } + + @Test + fun `balances from multiple address-type watchers are summed per device`() = test { + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + watcherEvents.emit( + "dev1|taproot" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 50uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.TAPROOT, + ) + ) + + assertEquals(150uL, sut.wallets.value.single().balanceSats) + assertEquals(150uL, sut.totalSats.value) + } + + @Test + fun `starts watchers only for the address types the user monitors`() = test { + storeData.value = HwWalletData( + knownDevices = listOf( + device.copy( + xpubs = mapOf( + "nativeSegwit" to "zpubNS", + "taproot" to "zpubTR", + "legacy" to "xpubLG", + ) + ) + ) + ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + createRepo() + + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull()) + verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull()) + verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull()) + } + + @Test + fun `emits received tx only for new inbound transactions after the baseline sync`() = test { + val sut = createRepo() + val received = mutableListOf() + val job = launch { sut.receivedTxs.collect { received += it } } + + // Baseline: full history delivered on watcher start must not emit. + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = listOf(receivedTransaction(amount = 100uL)), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(0, received.size) + + // New inbound tx after the baseline emits once. + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 150uL), + transactions = listOf( + receivedTransaction(amount = 100uL), + receivedTransaction(amount = 50uL).copy(txid = "t2"), + ), + txCount = 2u, + blockHeight = 2u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL)), received) + + // Re-delivering the same set (e.g. confirmation update) must not emit again. + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 150uL), + transactions = listOf( + receivedTransaction(amount = 100uL), + receivedTransaction(amount = 50uL).copy(txid = "t2"), + ), + txCount = 2u, + blockHeight = 3u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(1, received.size) + + job.cancel() + } + + @Test + fun `does not emit received tx for new outbound transactions`() = test { + val sut = createRepo() + val received = mutableListOf() + val job = launch { sut.receivedTxs.collect { received += it } } + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 40uL), + transactions = listOf( + receivedTransaction(amount = 60uL).copy(txid = "t3", direction = TxDirection.SENT), + ), + txCount = 1u, + blockHeight = 2u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(0, received.size) + job.cancel() + } + + @Test + fun `shows one wallet without double counting when paired over bluetooth and usb`() = test { + val bleEntry = device.copy(id = "ble1", lastConnectedAt = 1L, xpubs = mapOf("nativeSegwit" to "zpubNS")) + val usbEntry = bleEntry.copy(id = "usb1", transportType = TransportType.USB, lastConnectedAt = 2L) + storeData.value = HwWalletData(knownDevices = listOf(bleEntry, usbEntry)) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + val sut = createRepo() + + verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull()) + verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull()) + + watcherEvents.emit( + "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 421_900uL), + transactions = listOf(receivedTransaction(amount = 421_900uL)), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + val wallet = sut.wallets.value.single() + assertEquals(421_900uL, wallet.balanceSats) + assertEquals(421_900uL, sut.totalSats.value) + assertEquals(1, wallet.activities.size) + assertEquals(TransportType.USB, wallet.transportType) + } + + @Test + fun `connected entry wins identity for a wallet paired over both transports`() = test { + val bleEntry = device.copy(id = "ble1", lastConnectedAt = 2L, xpubs = mapOf("nativeSegwit" to "zpubNS")) + val usbEntry = bleEntry.copy(id = "usb1", transportType = TransportType.USB, lastConnectedAt = 1L) + storeData.value = HwWalletData(knownDevices = listOf(bleEntry, usbEntry)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "usb1", features = mock()), + ) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + val sut = createRepo() + + val wallet = sut.wallets.value.single() + assertEquals("usb1", wallet.id) + assertEquals(TransportType.USB, wallet.transportType) + assertEquals(true, wallet.isConnected) + } + + @Test + fun `keeps a stale watcher until stopping it succeeds`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS"))) + ) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.failure(AppError("stop failed"))) + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + // Stop fails: the watcher data must survive so the balance is not silently wrong. + settingsData.value = SettingsData(addressTypesToMonitor = emptyList()) + assertEquals(100uL, sut.totalSats.value) + + // Stop succeeds on a later sync: the watcher data is finally dropped. + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("taproot")) + assertEquals(0uL, sut.totalSats.value) + } + + @Test + fun `forwards transport restored to the trezor repo`() = test { + val sut = createRepo() + + sut.onTransportRestored(TransportType.USB) + + verify(trezorRepo).onTransportRestored(TransportType.USB) + } + + @Test + fun `forwards pairing code calls to the trezor repo`() = test { + val sut = createRepo() + + sut.submitPairingCode("123456") + sut.cancelPairingCode() + + verify(trezorRepo).submitPairingCode("123456") + verify(trezorRepo).cancelPairingCode() + } + + @Test + fun `starts watchers on the network configured in Env`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS"))) + ) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + createRepo() + + verify(trezorRepo).startWatcher( + watcherId = any(), + extendedKey = any(), + network = eq(Env.network.toCoreNetwork()), + gapLimit = any(), + accountType = anyOrNull(), + ) + } + + private fun walletBalance(total: ULong) = WalletBalance( + confirmed = total, + immature = 0uL, + trustedPending = 0uL, + untrustedPending = 0uL, + spendable = total, + total = total, + ) + + private fun receivedTransaction(amount: ULong) = HistoryTransaction( + txid = "t1", + received = amount, + sent = 0uL, + net = amount.toLong(), + fee = null, + amount = amount, + direction = TxDirection.RECEIVED, + blockHeight = 850_000u, + timestamp = 1_700_000_000uL, + confirmations = 3u, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 1c2782c3f1..65b6797ad3 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -11,6 +11,7 @@ import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.WalletSelection import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Rule import org.junit.Test @@ -20,11 +21,13 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.data.TrezorStore +import to.bitkit.data.HwWalletStore import to.bitkit.env.Env +import to.bitkit.models.TransportType import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler @@ -34,7 +37,11 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) +@Suppress("LargeClass") class TrezorRepoTest : BaseUnitTest() { companion object Fixtures { @@ -55,7 +62,7 @@ class TrezorRepoTest : BaseUnitTest() { private val trezorService = mock() private val trezorTransport = mock() private val trezorUiHandler = mock() - private val trezorStore = mock() + private val hwWalletStore = mock() private val prefs = mock() private val prefsEditor = mock() @@ -70,10 +77,11 @@ class TrezorRepoTest : BaseUnitTest() { whenever(prefsEditor.putString(any(), any())).thenReturn(prefsEditor) whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(false)) whenever(trezorTransport.externalDisconnect).thenReturn(MutableSharedFlow()) + whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) whenever(context.filesDir).thenReturn(tempFolder.root) - whenever { trezorStore.loadKnownDevices() }.thenReturn(emptyList()) + whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) } private fun createSut(): TrezorRepo = TrezorRepo( @@ -81,7 +89,8 @@ class TrezorRepoTest : BaseUnitTest() { trezorService = trezorService, trezorTransport = trezorTransport, trezorUiHandler = trezorUiHandler, - trezorStore = trezorStore, + hwWalletStore = hwWalletStore, + clock = Clock.System, ioDispatcher = testDispatcher, ) @@ -112,17 +121,19 @@ class TrezorRepoTest : BaseUnitTest() { on { this.model }.thenReturn(model) } + @Suppress("LongParameterList") private fun mockKnownDevice( id: String = DEVICE_ID, name: String? = DEVICE_NAME, path: String = DEVICE_PATH, label: String? = DEVICE_LABEL, model: String? = DEVICE_MODEL, + transportType: TransportType = TransportType.USB, ) = KnownDevice( id = id, name = name, path = path, - transportType = KnownDeviceTransportType.USB, + transportType = transportType, label = label, model = model, lastConnectedAt = 123L, @@ -176,7 +187,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val known = mockDeviceInfo() val nearby = mockDeviceInfo(id = "device-456", path = "/dev/trezor1") - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.scan()).thenReturn(listOf(known, nearby)) sut = createSut() @@ -204,6 +215,185 @@ class TrezorRepoTest : BaseUnitTest() { // region connect + @Test + fun `transport restored auto-reconnects to a known device`() = test { + val transportRestored = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + transportRestored.emit(TransportType.USB) + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + } + + @Test + fun `transport restored retries reconnect until the device is discoverable`() = test { + val transportRestored = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + // A device is usually not advertising yet right after the transport returns. + whenever(trezorService.scan()).thenReturn(emptyList(), listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + transportRestored.emit(TransportType.USB) + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + verify(trezorService, times(2)).scan() + } + + @Test + fun `reconnect prefers the transport that came back`() = test { + val features = mockFeatures() + val bleDevice = mockDeviceInfo(id = "ble-1", transportType = TrezorTransportType.BLUETOOTH, path = "ble-path") + val usbDevice = mockDeviceInfo(id = "usb-1", transportType = TrezorTransportType.USB, path = "usb-path") + whenever(hwWalletStore.loadKnownDevices()).thenReturn( + listOf( + mockKnownDevice(id = "ble-1", transportType = TransportType.BLUETOOTH), + mockKnownDevice(id = "usb-1"), + ), + ) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(bleDevice, usbDevice)) + whenever(trezorService.connect(eq("usb-1"), any())).thenReturn(features) + sut = createSut() + + sut.onTransportRestored(TransportType.USB) + advanceUntilIdle() + + verify(trezorService).connect(eq("usb-1"), any()) + verify(trezorService, never()).connect(eq("ble-1"), any()) + } + + @Test + fun `repeated transport restored triggers run a single reconnect`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + repeat(3) { sut.onTransportRestored(TransportType.USB) } + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + verify(trezorService, times(1)).scan() + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + } + + @Test + fun `autoReconnect bails while device awaits pin entry`() = test { + whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(true)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + sut = createSut() + + val result = sut.autoReconnect() + + assertTrue(result.isFailure) + verify(trezorService, never()).disconnect() + verify(trezorService, never()).scan() + } + + @Test + fun `transport restored skips reconnect while device awaits pairing code`() = test { + val transportRestored = MutableSharedFlow() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(true)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + sut = createSut() + + transportRestored.emit(TransportType.USB) + advanceUntilIdle() + + verify(trezorService, never()).disconnect() + verify(trezorService, never()).scan() + } + + @Test + fun `onTransportRestored auto-reconnects to a known device`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.onTransportRestored(TransportType.USB) + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + } + + @Test + fun `autoReconnect resets a stale session before scanning`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + // The core still reports a session although the transport dropped underneath it. + whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + sut.initialize() + + val result = sut.autoReconnect() + + assertTrue(result.isSuccess) + verify(trezorService).disconnect() + assertNotNull(sut.state.value.connected) + } + + @Test + fun `transport restored does not reconnect when a device is already connected`() = test { + val transportRestored = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + sut.scan() + sut.connect(DEVICE_ID) + + transportRestored.emit(TransportType.USB) + advanceUntilIdle() + + verify(trezorService, times(1)).scan() + } + + @Test + fun `external disconnect clears the connected device while no screen observes it`() = test { + val externalDisconnect = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.externalDisconnect).thenReturn(externalDisconnect) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + sut.scan() + sut.connect(DEVICE_ID) + assertNotNull(sut.state.value.connected) + + externalDisconnect.emit(DEVICE_PATH) + + assertNull(sut.state.value.connected) + assertEquals("Device disconnected", sut.state.value.error) + } + @Test fun `connect should return features and update connectedDevice state`() = test { val features = mockFeatures() @@ -237,10 +427,10 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val captor = argumentCaptor>() - verify(trezorStore).saveKnownDevices(captor.capture()) + verify(hwWalletStore).saveKnownDevices(captor.capture()) val saved = captor.firstValue.single() assertEquals(DEVICE_ID, saved.id) - assertEquals(KnownDeviceTransportType.USB, saved.transportType) + assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) } @@ -501,7 +691,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val device = mockDeviceInfo() val features = mockFeatures() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.scan()).thenReturn(listOf(device)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) whenever(trezorService.isConnected()).thenReturn(false) @@ -525,7 +715,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val device = mockDeviceInfo() val features = mockFeatures() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.scan()).thenReturn(listOf(device)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) sut = createSut() @@ -582,7 +772,7 @@ class TrezorRepoTest : BaseUnitTest() { val device = mockDeviceInfo() val features = mockFeatures() val addressResponse = mock() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.isConnected()).thenReturn(false) whenever(trezorService.scan()).thenReturn(listOf(device)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) @@ -615,7 +805,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val features = mockFeatures() val device = mockDeviceInfo() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) whenever(trezorService.scan()).thenReturn(listOf(device)) sut = createSut() @@ -635,7 +825,7 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals("disconnect failed", sut.state.value.error) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) verify(trezorService).clearCredentials(DEVICE_ID) - verify(trezorStore).saveKnownDevices(emptyList()) + verify(hwWalletStore).saveKnownDevices(emptyList()) } // endregion diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 27144b7162..c442b3e8af 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.GetAddressResponse import com.synonym.bitkitcore.GetAddressesResponse +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -54,6 +55,7 @@ class WalletRepoTest : BaseUnitTest() { private val transferRepo = mock() private val onchainService = mock() private val activityRepo = mock() + private val hwWalletRepo = mock() companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -86,6 +88,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = "", onchainAddress = ADDRESS))) whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull())) @@ -134,6 +137,7 @@ class WalletRepoTest : BaseUnitTest() { privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, transferRepo = transferRepo, activityRepo = activityRepo, + hwWalletRepo = hwWalletRepo, ) @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index b9002733a7..b1f365f592 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -34,7 +34,6 @@ class TrezorViewModelTest : BaseUnitTest() { private val trezorRepo: TrezorRepo = mock() private val trezorStateFlow = MutableStateFlow(TrezorState()) - private val needsPairingCodeFlow = MutableStateFlow(false) private val needsPinEntryFlow = MutableStateFlow(false) private val walletModeFlow = MutableStateFlow(TrezorWalletMode.STANDARD) private val watcherEventsFlow = MutableSharedFlow>() @@ -44,11 +43,9 @@ class TrezorViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(trezorRepo.state).thenReturn(trezorStateFlow) - whenever(trezorRepo.needsPairingCode).thenReturn(needsPairingCodeFlow) whenever(trezorRepo.needsPinEntry).thenReturn(needsPinEntryFlow) whenever(trezorRepo.walletMode).thenReturn(walletModeFlow) whenever(trezorRepo.watcherEvents).thenReturn(watcherEventsFlow) - whenever(trezorRepo.observeExternalDisconnects(any())).then { } sut = createViewModel() } @@ -497,21 +494,6 @@ class TrezorViewModelTest : BaseUnitTest() { verify(trezorRepo).clearError() } - @Test - fun `submitPairingCode should call trezorRepo submitPairingCode`() { - val code = "123456" - sut.submitPairingCode(code) - - verify(trezorRepo).submitPairingCode(code) - } - - @Test - fun `cancelPairingCode should call trezorRepo cancelPairingCode`() { - sut.cancelPairingCode() - - verify(trezorRepo).cancelPairingCode() - } - @Test fun `hasKnownDevices should delegate to trezorRepo`() { whenever(trezorRepo.hasKnownDevices()).thenReturn(true) diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index cb53f3900f..585eb8f0ff 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -1,5 +1,6 @@ package to.bitkit.usecases +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -19,6 +20,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity import to.bitkit.models.TransferType +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo @@ -31,6 +33,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val lightningRepo: LightningRepo = mock() private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() + private val hwWalletRepo: HwWalletRepo = mock() private lateinit var sut: DeriveBalanceStateUseCase @@ -40,6 +43,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) wheneverBlocking { lightningRepo.getChannelFundableBalance() }.thenReturn(0uL) wheneverBlocking { @@ -51,6 +55,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { lightningRepo = lightningRepo, transferRepo = transferRepo, settingsStore = settingsStore, + hwWalletRepo = hwWalletRepo, ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt new file mode 100644 index 0000000000..165e8bcc2b --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -0,0 +1,115 @@ +package to.bitkit.viewmodels + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.ext.create +import to.bitkit.ext.rawId +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.ActivityState +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.wallets.activity.components.ActivityTab +import kotlin.test.assertEquals + +class ActivityListViewModelTest : BaseUnitTest() { + + private val activityRepo = mock() + private val hwWalletRepo = mock() + private val pubkyRepo = mock() + private val settingsStore = mock() + + private val dbActivity = onchainActivity(id = "db1", txType = PaymentType.SENT, timestamp = 200uL) + private val hwActivity = onchainActivity(id = "hw1", txType = PaymentType.RECEIVED, timestamp = 100uL) + + @Before + fun setUp() { + whenever(activityRepo.state).thenReturn(MutableStateFlow(ActivityState())) + whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) + whenever { activityRepo.syncActivities() }.thenReturn(Result.success(Unit)) + whenever { activityRepo.getTxIdsInBoostTxIds() }.thenReturn(emptySet()) + whenever { + activityRepo.getActivities( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + }.thenReturn(Result.success(listOf(dbActivity))) + whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(settingsStore.isPaykitEnabled).thenReturn(MutableStateFlow(false)) + } + + private fun createViewModel() = ActivityListViewModel( + bgDispatcher = testDispatcher, + activityRepo = activityRepo, + hwWalletRepo = hwWalletRepo, + pubkyRepo = pubkyRepo, + settingsStore = settingsStore, + ) + + @Test + fun `filtered activities merge hardware activities newest first`() = test { + val sut = createViewModel() + advanceUntilIdle() + + assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) + } + + @Test + fun `filtered activities exclude hardware activities not matching the tab`() = test { + val sut = createViewModel() + sut.setTab(ActivityTab.SENT) + advanceUntilIdle() + + assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + } + + @Test + fun `filtered activities exclude hardware activities when a tag filter is active`() = test { + val sut = createViewModel() + sut.toggleTag("tag1") + advanceUntilIdle() + + assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + } + + @Test + fun `hardware ids expose the hardware activity ids`() = test { + val sut = createViewModel() + val job = launch { sut.hardwareIds.collect {} } + advanceUntilIdle() + + assertEquals(setOf("hw1"), sut.hardwareIds.value) + job.cancel() + } + + private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( + OnchainActivity.create( + id = id, + txType = txType, + txId = id, + value = 1_000uL, + fee = 1uL, + address = "bc1", + timestamp = timestamp, + confirmed = true, + ) + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 10428d9e5a..48b151aeae 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -42,6 +42,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.TransactionSpeed +import to.bitkit.models.TransportType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -50,6 +51,7 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PaymentPendingException @@ -72,6 +74,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.sheets.HardwareRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.usecases.FormatMoneyValue import to.bitkit.utils.AppError @@ -94,6 +97,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val context = mock() private val lightningRepo = mock() private val walletRepo = mock() + private val hwWalletRepo = mock() private val settingsStore = mock() private val currencyRepo = mock() private val connectivityRepo = mock() @@ -121,6 +125,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val toastManager = mock() private val balanceState = MutableStateFlow(BalanceState()) + private val needsPairingCode = MutableStateFlow(false) private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) @@ -145,6 +150,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(hwWalletRepo.receivedTxs).thenReturn(MutableSharedFlow()) + whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) @@ -219,6 +226,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { lightningRepo = lightningRepo, pendingPaymentRepo = pendingPaymentRepo, walletRepo = walletRepo, + hwWalletRepo = hwWalletRepo, backupRepo = backupRepo, settingsStore = settingsStore, currencyRepo = currencyRepo, @@ -244,6 +252,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyRepo = pubkyRepo, ) + @Test + fun `onUsbDeviceAttached forwards to the hardware wallet repo`() = test { + sut.onUsbDeviceAttached() + + verify(hwWalletRepo).onTransportRestored(TransportType.USB) + } + + @Test + fun `pairing code request shows and hides the pair device sheet`() = test { + needsPairingCode.value = true + advanceUntilIdle() + + assertEquals(Sheet.Hardware(route = HardwareRoute.PairingCode), sut.currentSheet.value) + + needsPairingCode.value = false + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @Test + fun `pairing code request does not interrupt a high priority sheet`() = test { + sut.showSheet(Sheet.Pin()) + advanceUntilIdle() + + needsPairingCode.value = true + advanceUntilIdle() + + assertEquals(Sheet.Pin(), sut.currentSheet.value) + } + + @Test + fun `submitPairingCode forwards to the hardware wallet repo`() = test { + sut.submitPairingCode("123456") + + verify(hwWalletRepo).submitPairingCode("123456") + } + + @Test + fun `cancelPairingCode forwards to the hardware wallet repo`() = test { + sut.cancelPairingCode() + + verify(hwWalletRepo).cancelPairingCode() + } + @Test fun `canSwitchWallet is false when not unified`() = test { sut.setSendEvent(SendEvent.AmountChange(1000u)) diff --git a/changelog.d/next/999.added.md b/changelog.d/next/999.added.md new file mode 100644 index 0000000000..c69580438a --- /dev/null +++ b/changelog.d/next/999.added.md @@ -0,0 +1 @@ +Show paired Trezor hardware wallet balances and activity on the wallet home screen, with automatic reconnection and pairing-code entry, and a received sheet for new incoming transactions.