diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index d1b61c85b..1d2325926 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -106,8 +106,8 @@ fun String.toAddressType(): AddressType? = when (this) { val ALL_ADDRESS_TYPE_STRINGS = listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot") fun String.addressTypeFromAddress(): String? = when { - startsWith("bc1p") || startsWith("tb1p") -> "taproot" - startsWith("bc1") || startsWith("tb1") -> "nativeSegwit" + startsWith("bc1p") || startsWith("tb1p") || startsWith("bcrt1p") -> "taproot" + startsWith("bc1") || startsWith("tb1") || startsWith("bcrt1") -> "nativeSegwit" startsWith("3") || startsWith("2") -> "nestedSegwit" startsWith("1") || startsWith("m") || startsWith("n") -> "legacy" else -> null diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 389b711df..fe09f1188 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -676,6 +676,7 @@ class LightningRepo @Inject constructor( val previousSettings = settingsStore.data.first() val oldSelected = previousSettings.selectedAddressType val oldMonitored = previousSettings.addressTypesToMonitor + val addressType = selectedType.toAddressType() ?: AddressType.P2WPKH suspend fun rollback() = settingsStore.update { it.copy(selectedAddressType = oldSelected, addressTypesToMonitor = oldMonitored) } @@ -684,7 +685,8 @@ class LightningRepo @Inject constructor( settingsStore.update { it.copy(selectedAddressType = selectedType, addressTypesToMonitor = monitoredTypes) } - restartNodeOrRollback(onRollback = { rollback() }) + lightningService.setPrimaryAddressType(addressType) + sync().onFailure { Logger.warn("Sync after address type change failed", it, context = TAG) } Unit }.onFailure { rollback() @@ -717,7 +719,12 @@ class LightningRepo @Inject constructor( runCatching { settingsStore.update { it.copy(addressTypesToMonitor = newMonitored) } - restartNodeOrRollback(onRollback = { rollback() }) + if (enabled) { + lightningService.addAddressTypeToMonitor(addressType) + } else { + lightningService.removeAddressTypeFromMonitor(addressType) + } + sync().onFailure { Logger.warn("Sync after monitoring change failed", it, context = TAG) } Unit }.onFailure { rollback() @@ -749,24 +756,6 @@ class LightningRepo @Inject constructor( return null } - @Suppress("ThrowsCount") - private suspend fun restartNodeOrRollback(onRollback: suspend () -> Unit) { - waitForNodeToStop().onFailure { - onRollback() - throw it - } - stop().onFailure { - onRollback() - throw it - } - start(shouldRetry = false).onFailure { - onRollback() - restartWithPreviousConfig() - throw it - } - sync().onFailure { Logger.warn("Sync after address type change failed", it, context = TAG) } - } - fun isChangingAddressType(): Boolean = isChangingAddressType.get() suspend fun pruneEmptyAddressTypesAfterRestore(): Result = withContext(bgDispatcher) { @@ -791,12 +780,13 @@ class LightningRepo @Inject constructor( val newMonitored = monitored.filter { it !in toRemove } settingsStore.update { it.copy(addressTypesToMonitor = newMonitored) } - stop().onFailure { return@withContext Result.failure(it) } - start(shouldRetry = false).onFailure { - settingsStore.update { it.copy(addressTypesToMonitor = monitored) } - return@withContext Result.failure(it) + for (typeStr in toRemove) { + val type = typeStr.toAddressType() ?: continue + runCatching { lightningService.removeAddressTypeFromMonitor(type) }.onFailure { + Logger.error("Failed to remove address type $typeStr from monitor", it, context = TAG) + } } - sync().onFailure { Logger.warn("Initial sync after prune failed", it, context = TAG) } + sync().onFailure { Logger.warn("Sync after prune failed", it, context = TAG) } Result.success(Unit) } @@ -983,12 +973,15 @@ class LightningRepo @Inject constructor( val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() - // use passed utxos if specified, otherwise run auto coin select if enabled - val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend(sats, satsPerVByte) + // transfer send-all: skip UTXO selection to avoid LDK buffer; else use passed or auto-selected + val utxosForSend = when { + isTransfer && isMaxAmount -> null + else -> utxosToSpend ?: determineUtxosToSpend(sats, satsPerVByte) + } - Logger.debug("UTXOs selected to spend: $finalUtxosToSpend", context = TAG) + Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) - val txId = lightningService.send(address, sats, satsPerVByte, finalUtxosToSpend, isMaxAmount) + val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( paymentId = txId, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index f62b5cb32..1b88261ea 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -930,6 +930,25 @@ class LightningService @Inject constructor( n.getBalanceForAddressType(addressType.toLdkAddressType()) } + suspend fun setPrimaryAddressType(addressType: AddressType) = ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + n.setPrimaryAddressTypeWithMnemonic(addressType.toLdkAddressType(), mnemonic, passphrase) + } + + suspend fun addAddressTypeToMonitor(addressType: AddressType) = ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + n.addAddressTypeToMonitorWithMnemonic(addressType.toLdkAddressType(), mnemonic, passphrase) + } + + suspend fun removeAddressTypeFromMonitor(addressType: AddressType) = ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + n.removeAddressTypeFromMonitor(addressType.toLdkAddressType()) + } + private fun AddressType.toLdkAddressType(): LdkAddressType = when (this) { AddressType.P2PKH -> LdkAddressType.LEGACY AddressType.P2SH -> LdkAddressType.NESTED_SEGWIT diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt index 9ea5194cf..6a01aea1f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt @@ -1,26 +1,15 @@ package to.bitkit.ui.settings.advanced -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -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.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.layout.ContentScale 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 @@ -31,21 +20,15 @@ import com.synonym.bitkitcore.AddressType import to.bitkit.R import to.bitkit.models.addressTypeInfo import to.bitkit.models.toSettingsString -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.components.settings.SettingsSwitchRow -import to.bitkit.ui.navigateToHome import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent private val ADDRESS_TYPES = listOf( AddressType.P2PKH, @@ -69,12 +52,6 @@ fun AddressTypePreferenceScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - viewModel.navigateToHome.collect { - navController.navigateToHome() - } - } - Content( uiState = uiState, onBack = { navController.popBackStack() }, @@ -90,145 +67,58 @@ private fun Content( onSelectAddressType: (AddressType) -> Unit = {}, onSetMonitoring: (AddressType, Boolean) -> Unit = { _, _ -> }, ) { - Box( - content = { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.settings__addr_type__title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - Column( - content = { - SectionHeader(title = stringResource(R.string.settings__addr_type__primary)) - - ADDRESS_TYPES.forEach { type -> - val info = type.addressTypeInfo() - SettingsButtonRow( - title = "${info.shortName} ${info.example}", - subtitle = info.description, - value = SettingsButtonValue.BooleanValue(uiState.selectedAddressType == type), - onClick = { onSelectAddressType(type) }, - modifier = Modifier.testTag(type.toAddressTypeE2eId()), - ) - } - - if (uiState.showMonitoredTypes) { - SectionHeader(title = stringResource(R.string.settings__addr_type__monitoring)) + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.settings__addr_type__title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .testTag("AddressTypePreference"), + ) { + SectionHeader(title = stringResource(R.string.settings__addr_type__primary)) - ADDRESS_TYPES.forEach { type -> - val info = type.addressTypeInfo() - val isMonitored = type.toSettingsString() in uiState.monitoredTypes - val isSelectedType = uiState.selectedAddressType == type - Column( - content = { - SettingsSwitchRow( - title = "${info.shortName} ${info.shortExample}", - subtitle = if (isSelectedType) { - stringResource(R.string.settings__adv__addr_type_currently_selected) - } else { - null - }, - isChecked = isMonitored, - onClick = { if (!isSelectedType) onSetMonitoring(type, !isMonitored) }, - modifier = Modifier.testTag("MonitorToggle-${type.toAddressTypeE2eId()}"), - ) - }, - modifier = Modifier.alpha(if (isSelectedType) 0.5f else 1f), - ) - } - } - }, - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .testTag("AddressTypePreference"), - ) - } - if (uiState.isLoading) { - Box( - content = { - AddressTypeLoadingContent( - targetAddressType = uiState.loadingAddressType, - isMonitoringChange = uiState.isMonitoringChange, - ) - }, - modifier = Modifier - .fillMaxSize() - .background(Colors.Black), + ADDRESS_TYPES.forEach { type -> + val info = type.addressTypeInfo() + SettingsButtonRow( + title = "${info.shortName} ${info.example}", + subtitle = info.description, + value = SettingsButtonValue.BooleanValue(uiState.selectedAddressType == type), + onClick = { onSelectAddressType(type) }, + modifier = Modifier.testTag(type.toAddressTypeE2eId()), ) } - }, - modifier = Modifier.fillMaxSize(), - ) -} -@Composable -private fun AddressTypeLoadingContent( - targetAddressType: AddressType?, - isMonitoringChange: Boolean, - modifier: Modifier = Modifier, -) { - val navTitle = if (isMonitoringChange) { - stringResource(R.string.settings__addr_type__loading_nav_monitoring) - } else { - stringResource(R.string.settings__addr_type__loading_nav_address) - } - val headline = if (targetAddressType != null && !isMonitoringChange) { - stringResource(R.string.settings__addr_type__loading_headline) - .replace("{type}", "${targetAddressType.addressTypeInfo().shortName}") - } else { - stringResource(R.string.settings__addr_type__loading_updating) - } - val description = stringResource(R.string.settings__addr_type__loading_desc) + if (uiState.showMonitoredTypes) { + SectionHeader(title = stringResource(R.string.settings__addr_type__monitoring)) - Column( - content = { - AppTopBar( - titleText = navTitle, - onBackClick = null, - actions = {}, - ) - Column( - content = { - FillHeight() - Image( - painter = painterResource(R.drawable.wallet), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - FillHeight() - Column( - verticalArrangement = Arrangement.spacedBy(14.dp), - content = { - Display( - text = headline.withAccent(accentColor = Colors.Brand), - ) - BodyM(text = description, color = Colors.White64) + ADDRESS_TYPES.forEach { type -> + val info = type.addressTypeInfo() + val isMonitored = type.toSettingsString() in uiState.monitoredTypes + val isSelectedType = uiState.selectedAddressType == type + SettingsSwitchRow( + title = "${info.shortName} ${info.shortExample}", + subtitle = if (isSelectedType) { + stringResource(R.string.settings__adv__addr_type_currently_selected) + } else { + null }, - modifier = Modifier.fillMaxWidth(), - ) - VerticalSpacer(32.dp) - CircularProgressIndicator( - color = Colors.White32, - strokeWidth = 3.dp, + isChecked = isMonitored, + onClick = { if (!isSelectedType) onSetMonitoring(type, !isMonitored) }, modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(32.dp), + .alpha(if (isSelectedType) 0.5f else 1f) + .testTag("MonitorToggle-${type.toAddressTypeE2eId()}"), ) - VerticalSpacer(32.dp) - }, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - ) - }, - modifier = modifier - .fillMaxSize() - .padding(16.dp), - ) + } + } + + VerticalSpacer(16.dp) + } + } } @Preview diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt index 4b2f78066..971cf2996 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt @@ -7,16 +7,12 @@ import com.synonym.bitkitcore.AddressType import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -40,9 +36,6 @@ class AddressTypePreferenceViewModel @Inject constructor( private val _uiState = MutableStateFlow(AddressTypePreferenceUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _navigateToHome = MutableSharedFlow(extraBufferCapacity = 1) - val navigateToHome: SharedFlow = _navigateToHome.asSharedFlow() - init { loadState() } @@ -68,37 +61,32 @@ class AddressTypePreferenceViewModel @Inject constructor( if (_uiState.value.selectedAddressType == addressType) return viewModelScope.launch(bgDispatcher) { - _uiState.update { it.copy(isLoading = true, loadingAddressType = addressType, isMonitoringChange = false) } - - val result = withTimeoutOrNull(60_000L) { - runCatching { - val currentMonitored = _uiState.value.monitoredTypes.toMutableSet() - currentMonitored.add(addressType.toSettingsString()) - lightningRepo.updateAddressType( - selectedType = addressType.toSettingsString(), - monitoredTypes = currentMonitored.toList(), - ).getOrThrow() - walletRepo.refreshReceiveAddressAfterTypeChange() - } + _uiState.update { it.copy(isLoading = true) } + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.settings__addr_type__applying), + autoHide = false, + ) + + val currentMonitored = _uiState.value.monitoredTypes.toMutableSet() + currentMonitored.add(addressType.toSettingsString()) + val result = lightningRepo.updateAddressType( + selectedType = addressType.toSettingsString(), + monitoredTypes = currentMonitored.toList(), + ).onSuccess { + walletRepo.refreshReceiveAddressAfterTypeChange() } - _uiState.update { it.copy(isLoading = false, loadingAddressType = null) } + _uiState.update { it.copy(isLoading = false) } loadState() - when { - result == null -> ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__addr_type__timeout), - description = context.getString(R.string.settings__addr_type__timeout_desc), + if (result.isSuccess) { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.settings__addr_type__settings_updated), ) - result.isSuccess -> { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.settings__addr_type__settings_updated), - ) - viewModelScope.launch { _navigateToHome.emit(Unit) } - } - else -> ToastEventBus.send( + } else { + ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.common__error), description = result.exceptionOrNull()?.message, @@ -114,44 +102,41 @@ class AddressTypePreferenceViewModel @Inject constructor( if (isMonitored == enabled) return viewModelScope.launch(bgDispatcher) { - _uiState.update { it.copy(isLoading = true, loadingAddressType = addressType, isMonitoringChange = true) } + _uiState.update { it.copy(isLoading = true) } + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.settings__addr_type__applying), + autoHide = false, + ) - val repoResult = withTimeoutOrNull(60_000L) { - lightningRepo.setMonitoring(addressType, enabled) - } + val repoResult = lightningRepo.setMonitoring(addressType, enabled) - _uiState.update { it.copy(isLoading = false, loadingAddressType = null) } + _uiState.update { it.copy(isLoading = false) } loadState() - when { - repoResult == null -> ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__addr_type__timeout), - description = context.getString(R.string.settings__addr_type__timeout_desc), - ) - repoResult.isSuccess -> ToastEventBus.send( + if (repoResult.isSuccess) { + ToastEventBus.send( type = Toast.ToastType.SUCCESS, title = context.getString(R.string.settings__addr_type__settings_updated), ) - else -> { - val ex = repoResult.exceptionOrNull()?.message - val msg = when { - ex?.contains("has balance") == true -> - context.getString(R.string.settings__addr_type__disabled_has_balance) - ex?.contains("verify") == true -> - context.getString(R.string.settings__addr_type__disabled_verify_failed) - ex?.contains("Native SegWit or Taproot") == true -> - context.getString(R.string.settings__addr_type__disabled_native_required) - ex?.contains("currently selected") == true -> - context.getString(R.string.settings__addr_type__disabled_currently_selected) - else -> ex - } - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.common__error), - description = msg, - ) + } else { + val ex = repoResult.exceptionOrNull()?.message + val msg = when { + ex?.contains("has balance") == true -> + context.getString(R.string.settings__addr_type__disabled_has_balance) + ex?.contains("verify") == true -> + context.getString(R.string.settings__addr_type__disabled_verify_failed) + ex?.contains("Native SegWit or Taproot") == true -> + context.getString(R.string.settings__addr_type__disabled_native_required) + ex?.contains("currently selected") == true -> + context.getString(R.string.settings__addr_type__disabled_currently_selected) + else -> ex } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.common__error), + description = msg, + ) } } } @@ -162,6 +147,4 @@ data class AddressTypePreferenceUiState( val monitoredTypes: Set = setOf("nativeSegwit"), val showMonitoredTypes: Boolean = false, val isLoading: Boolean = false, - val loadingAddressType: AddressType? = null, - val isMonitoringChange: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e5d5946b7..b754e9c3f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -28,7 +28,6 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore -import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -195,17 +194,20 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() - // Calculate if change would be dust and we should use sendAll val spendableBalance = lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL - val txFee = lightningRepo.calculateTotalFee( - amountSats = order.feeSat, + val allUtxos = lightningRepo.listSpendableOutputs().getOrNull() + val sendAllFee = lightningRepo.calculateTotalFee( + amountSats = spendableBalance, address = address, speed = speed, + utxosToSpend = allUtxos, ).getOrElse { 0uL } - val expectedChange = spendableBalance.toLong() - order.feeSat.toLong() - txFee.toLong() - val shouldUseSendAll = expectedChange >= 0 && expectedChange < Defaults.dustLimit.toInt() + val expectedChange = + spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() + val shouldUseSendAll = + expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS lightningRepo .sendOnChain( @@ -610,6 +612,7 @@ class TransferViewModel @Inject constructor( companion object { private const val TAG = "TransferViewModel" + private const val TRANSFER_SEND_ALL_THRESHOLD_SATS = 1000 private const val MIN_STEP_DELAY_MS = 500L private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7adc91121..9eadb617a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -534,6 +534,7 @@ No addresses found when searching for \"{searchTxt}\" Path: {path} {count, plural, one {Spend ₿ {fundsToSpend} From # address} other {Spend ₿ {fundsToSpend} From # addresses}} + Applying changes… Cannot disable monitoring: address type is currently selected Cannot disable monitoring: address type has balance At least one Native SegWit or Taproot wallet is required for Lightning channels. @@ -541,13 +542,6 @@ Monitor address types Primary address type Settings updated - Please wait while the wallet restarts… - Switching to {type} - Address Type - Address Monitoring - Updating Wallet - Operation timed out - The operation took too long. Please try again. Address type Currently selected Address Viewer diff --git a/app/src/test/java/to/bitkit/models/AddressTypeTest.kt b/app/src/test/java/to/bitkit/models/AddressTypeTest.kt new file mode 100644 index 000000000..4b4166fee --- /dev/null +++ b/app/src/test/java/to/bitkit/models/AddressTypeTest.kt @@ -0,0 +1,72 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.AddressType +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AddressTypeTest { + + @Test + fun `toSettingsString maps all types correctly`() { + assertEquals("taproot", AddressType.P2TR.toSettingsString()) + assertEquals("nativeSegwit", AddressType.P2WPKH.toSettingsString()) + assertEquals("nestedSegwit", AddressType.P2SH.toSettingsString()) + assertEquals("legacy", AddressType.P2PKH.toSettingsString()) + } + + @Test + fun `toAddressType maps all strings correctly`() { + assertEquals(AddressType.P2TR, "taproot".toAddressType()) + assertEquals(AddressType.P2WPKH, "nativeSegwit".toAddressType()) + assertEquals(AddressType.P2SH, "nestedSegwit".toAddressType()) + assertEquals(AddressType.P2PKH, "legacy".toAddressType()) + } + + @Test + fun `toAddressType returns null for unknown strings`() { + assertNull("unknown".toAddressType()) + assertNull("".toAddressType()) + } + + @Test + fun `toSettingsString and toAddressType are inverses`() { + val types = listOf(AddressType.P2TR, AddressType.P2WPKH, AddressType.P2SH, AddressType.P2PKH) + types.forEach { type -> + assertEquals(type, type.toSettingsString().toAddressType()) + } + } + + @Test + fun `addressTypeFromAddress detects taproot addresses`() { + assertEquals("taproot", "bc1pabc123".addressTypeFromAddress()) + assertEquals("taproot", "tb1pdef456".addressTypeFromAddress()) + assertEquals("taproot", "bcrt1pabc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress detects native segwit addresses`() { + assertEquals("nativeSegwit", "bc1qabc123".addressTypeFromAddress()) + assertEquals("nativeSegwit", "tb1qabc123".addressTypeFromAddress()) + assertEquals("nativeSegwit", "bcrt1qabc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress detects nested segwit addresses`() { + assertEquals("nestedSegwit", "3abc123".addressTypeFromAddress()) + assertEquals("nestedSegwit", "2abc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress detects legacy addresses`() { + assertEquals("legacy", "1abc123".addressTypeFromAddress()) + assertEquals("legacy", "mabc123".addressTypeFromAddress()) + assertEquals("legacy", "nabc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress returns null for unknown`() { + assertNull("xabc123".addressTypeFromAddress()) + assertNull("".addressTypeFromAddress()) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a14a47a8d..53eb38ceb 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -778,6 +778,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull()?.message?.contains("currently selected") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) } @Test @@ -800,6 +801,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull()?.message?.contains("Native SegWit or Taproot") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) } @Test @@ -822,10 +824,11 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull()?.message?.contains("verify") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) } @Test - fun `setMonitoring should succeed when enabling a type`() = test { + fun `setMonitoring should fail when disabling with balance greater than zero`() = test { startNodeForTesting() whenever( settingsStore.data @@ -833,19 +836,22 @@ class LightningRepoTest : BaseUnitTest() { flowOf( SettingsData( selectedAddressType = "nativeSegwit", - addressTypesToMonitor = listOf("nativeSegwit") + addressTypesToMonitor = listOf("nativeSegwit", "taproot") ) ) ) - whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 1_000uL, spendableSats = 1_000uL)) - val result = sut.setMonitoring(AddressType.P2TR, enabled = true) + val result = sut.setMonitoring(AddressType.P2TR, enabled = false) - assertTrue(result.isSuccess) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("has balance") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) } @Test - fun `setMonitoring should succeed when disabling when allowed`() = test { + fun `setMonitoring should succeed when enabling a type`() = test { startNodeForTesting() whenever( settingsStore.data @@ -853,21 +859,20 @@ class LightningRepoTest : BaseUnitTest() { flowOf( SettingsData( selectedAddressType = "nativeSegwit", - addressTypesToMonitor = listOf("nativeSegwit", "taproot") + addressTypesToMonitor = listOf("nativeSegwit") ) ) ) - whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) - .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) whenever { settingsStore.update(any()) }.thenReturn(Unit) - val result = sut.setMonitoring(AddressType.P2TR, enabled = false) + val result = sut.setMonitoring(AddressType.P2TR, enabled = true) assertTrue(result.isSuccess) + verify(lightningService).addAddressTypeToMonitor(AddressType.P2TR) } @Test - fun `setMonitoring should fail when disabling with balance greater than zero`() = test { + fun `setMonitoring should succeed when disabling when allowed`() = test { startNodeForTesting() whenever( settingsStore.data @@ -880,12 +885,13 @@ class LightningRepoTest : BaseUnitTest() { ) ) whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) - .thenReturn(AddressTypeBalance(totalSats = 1_000uL, spendableSats = 1_000uL)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + whenever { settingsStore.update(any()) }.thenReturn(Unit) val result = sut.setMonitoring(AddressType.P2TR, enabled = false) - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("has balance") == true) + assertTrue(result.isSuccess) + verify(lightningService).removeAddressTypeFromMonitor(AddressType.P2TR) } @Test @@ -901,10 +907,11 @@ class LightningRepoTest : BaseUnitTest() { val result = sut.updateAddressType("taproot", listOf("taproot", "nativeSegwit")) assertTrue(result.isSuccess) + verify(lightningService).setPrimaryAddressType(AddressType.P2TR) } @Test - fun `updateAddressType should fail when stop fails`() = test { + fun `updateAddressType should fail when setPrimaryAddressType fails`() = test { startNodeForTesting() whenever( settingsStore.data @@ -912,30 +919,151 @@ class LightningRepoTest : BaseUnitTest() { flowOf(SettingsData(selectedAddressType = "nativeSegwit", addressTypesToMonitor = listOf("nativeSegwit"))) ) whenever { settingsStore.update(any()) }.thenReturn(Unit) - whenever(lightningService.node).thenReturn(null) - whenever(lightningService.stop()).thenThrow(RuntimeException("stop failed")) + whenever(lightningService.setPrimaryAddressType(any())) + .thenThrow(RuntimeException("setPrimaryAddressType failed")) val result = sut.updateAddressType("taproot", listOf("taproot", "nativeSegwit")) assertTrue(result.isFailure) + // Verify rollback happened (update called twice: once for new settings, once for rollback) + verifyBlocking(settingsStore, times(2)) { update(any()) } } @Test - fun `updateAddressType should fail when start fails`() = test { + fun `getChannelFundableBalance should sum spendable across monitored types excluding legacy`() = test { startNodeForTesting() - whenever( - settingsStore.data - ).thenReturn( - flowOf(SettingsData(selectedAddressType = "nativeSegwit", addressTypesToMonitor = listOf("nativeSegwit"))) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot", "legacy") + ) + ) ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2WPKH)) + .thenReturn(AddressTypeBalance(totalSats = 50_000uL, spendableSats = 40_000uL)) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 30_000uL, spendableSats = 20_000uL)) + whenever(lightningService.getBalanceForAddressType(AddressType.P2PKH)) + .thenReturn(AddressTypeBalance(totalSats = 10_000uL, spendableSats = 10_000uL)) + + val result = sut.getChannelFundableBalance() + + // 40_000 (P2WPKH) + 20_000 (P2TR) = 60_000; legacy 10_000 excluded + assertEquals(60_000uL, result) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should remove empty non-selected types`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot", "legacy") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + whenever(lightningService.getBalanceForAddressType(AddressType.P2PKH)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) whenever { settingsStore.update(any()) }.thenReturn(Unit) - whenever(lightningService.node).thenReturn(null) - whenever(lightningService.stop()).thenReturn(Unit) - whenever(lightningService.start(anyOrNull(), any())).thenThrow(RuntimeException("start failed")) - val result = sut.updateAddressType("taproot", listOf("taproot", "nativeSegwit")) + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService).removeAddressTypeFromMonitor(AddressType.P2TR) + verify(lightningService).removeAddressTypeFromMonitor(AddressType.P2PKH) + // Selected type (nativeSegwit/P2WPKH) must not be removed + verify(lightningService, times(0)).removeAddressTypeFromMonitor(AddressType.P2WPKH) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should keep types with balance`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 5_000uL, spendableSats = 5_000uL)) + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should not remove last native witness type`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "legacy", + addressTypesToMonitor = listOf("legacy", "nativeSegwit") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2WPKH)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should skip when address type change in progress`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + // Start an address type change to set isChangingAddressType + val settingsFlow = MutableSharedFlow() + whenever(settingsStore.data).thenReturn(settingsFlow) + val scope = CoroutineScope(testDispatcher) + scope.async { sut.updateAddressType("taproot", listOf("taproot")) } + testScheduler.advanceUntilIdle() + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `setMonitoring should rollback on service failure`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit") + ) + ) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever(lightningService.addAddressTypeToMonitor(any())) + .thenThrow(RuntimeException("service error")) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = true) assertTrue(result.isFailure) + // Verify rollback happened (update called twice: once for new, once for rollback) + verifyBlocking(settingsStore, times(2)) { update(any()) } } @Test diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 18accded0..fbbb03f04 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -193,11 +193,20 @@ class WalletRepoTest : BaseUnitTest() { fun `restoreWallet should monitor all address types for restore`() = test { val mnemonic = "restore mnemonic" whenever(keychain.saveString(any(), any())).thenReturn(Unit) + var capturedSettings: SettingsData? = null + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + capturedSettings = transform(SettingsData()) + } val result = sut.restoreWallet(mnemonic, null) assertTrue(result.isSuccess) - verify(settingsStore).update(any()) + assertEquals("nativeSegwit", capturedSettings?.selectedAddressType) + assertEquals( + listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot"), + capturedSettings?.addressTypesToMonitor, + ) } @Test diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt index 2743f48c5..b1fa5af72 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt @@ -5,14 +5,15 @@ import app.cash.turbine.test import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any 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 org.mockito.kotlin.wheneverBlocking import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -32,9 +33,8 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { private lateinit var sut: AddressTypePreferenceViewModel + private val applyingChanges = "Applying changes…" private val settingsUpdated = "Settings updated" - private val timeoutTitle = "Timeout" - private val timeoutDesc = "Operation timed out" private val errorTitle = "Error" private val disabledHasBalance = "Address type has balance" private val disabledVerifyFailed = "Failed to verify balance" @@ -43,32 +43,29 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { @Before fun setUp() { - runBlocking { - whenever(context.getString(R.string.settings__addr_type__settings_updated)).thenReturn(settingsUpdated) - whenever(context.getString(R.string.settings__addr_type__timeout)).thenReturn(timeoutTitle) - whenever(context.getString(R.string.settings__addr_type__timeout_desc)).thenReturn(timeoutDesc) - whenever(context.getString(R.string.common__error)).thenReturn(errorTitle) - whenever(context.getString(R.string.settings__addr_type__disabled_has_balance)) - .thenReturn(disabledHasBalance) - whenever( - context.getString(R.string.settings__addr_type__disabled_verify_failed) - ).thenReturn(disabledVerifyFailed) - whenever( - context.getString(R.string.settings__addr_type__disabled_native_required) - ).thenReturn(disabledNativeRequired) - whenever( - context.getString(R.string.settings__addr_type__disabled_currently_selected) - ).thenReturn(disabledCurrentlySelected) - whenever(settingsStore.data).thenReturn( - flowOf( - SettingsData( - selectedAddressType = "nativeSegwit", - addressTypesToMonitor = listOf("nativeSegwit"), - isDevModeEnabled = true, - ) + whenever(context.getString(R.string.settings__addr_type__applying)).thenReturn(applyingChanges) + whenever(context.getString(R.string.settings__addr_type__settings_updated)).thenReturn(settingsUpdated) + whenever(context.getString(R.string.common__error)).thenReturn(errorTitle) + whenever(context.getString(R.string.settings__addr_type__disabled_has_balance)) + .thenReturn(disabledHasBalance) + whenever( + context.getString(R.string.settings__addr_type__disabled_verify_failed) + ).thenReturn(disabledVerifyFailed) + whenever( + context.getString(R.string.settings__addr_type__disabled_native_required) + ).thenReturn(disabledNativeRequired) + whenever( + context.getString(R.string.settings__addr_type__disabled_currently_selected) + ).thenReturn(disabledCurrentlySelected) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, ) ) - } + ) } private fun createSut(): AddressTypePreferenceViewModel = @@ -95,10 +92,8 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { @Test fun `setMonitoring success sends success toast`() = test { - runBlocking { - wheneverBlocking { lightningRepo.setMonitoring(AddressType.P2TR, true) } - .thenReturn(Result.success(Unit)) - } + whenever(lightningRepo.setMonitoring(AddressType.P2TR, true)) + .thenReturn(Result.success(Unit)) whenever(settingsStore.data).thenReturn( flowOf( SettingsData( @@ -116,18 +111,18 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { sut.setMonitoring(AddressType.P2TR, true) advanceUntilIdle() - assertTrue(toasts.isNotEmpty()) - assertEquals(Toast.ToastType.SUCCESS, toasts.first().type) - assertEquals(settingsUpdated, toasts.first().title) + assertTrue(toasts.size >= 2) + assertEquals(Toast.ToastType.INFO, toasts.first().type) + assertEquals(applyingChanges, toasts.first().title) + assertEquals(Toast.ToastType.SUCCESS, toasts.last().type) + assertEquals(settingsUpdated, toasts.last().title) collectJob.cancel() } @Test fun `setMonitoring failure sends error toast with mapped message`() = test { - runBlocking { - wheneverBlocking { lightningRepo.setMonitoring(AddressType.P2TR, false) } - .thenReturn(Result.failure(Exception("Cannot disable monitoring: address type has balance"))) - } + whenever(lightningRepo.setMonitoring(AddressType.P2TR, false)) + .thenReturn(Result.failure(Exception("Cannot disable monitoring: address type has balance"))) whenever(settingsStore.data).thenReturn( flowOf( SettingsData( @@ -146,18 +141,16 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(toasts.isNotEmpty()) - assertEquals(Toast.ToastType.WARNING, toasts.first().type) - assertEquals(errorTitle, toasts.first().title) - assertEquals(disabledHasBalance, toasts.first().description) + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(errorTitle, toasts.last().title) + assertEquals(disabledHasBalance, toasts.last().description) collectJob.cancel() } @Test fun `setMonitoring failure with currently selected sends mapped error toast`() = test { - runBlocking { - wheneverBlocking { lightningRepo.setMonitoring(AddressType.P2TR, false) } - .thenReturn(Result.failure(Exception("Cannot disable monitoring: address type is currently selected"))) - } + whenever(lightningRepo.setMonitoring(AddressType.P2TR, false)) + .thenReturn(Result.failure(Exception("Cannot disable monitoring: address type is currently selected"))) whenever(settingsStore.data).thenReturn( flowOf( SettingsData( @@ -176,16 +169,16 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(toasts.isNotEmpty()) - assertEquals(disabledCurrentlySelected, toasts.first().description) + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(errorTitle, toasts.last().title) + assertEquals(disabledCurrentlySelected, toasts.last().description) collectJob.cancel() } @Test fun `updateAddressType success sends success toast`() = test { - runBlocking { - wheneverBlocking { lightningRepo.updateAddressType(any(), any()) }.thenReturn(Result.success(Unit)) - wheneverBlocking { walletRepo.refreshReceiveAddressAfterTypeChange() }.thenReturn(Result.success(Unit)) - } + whenever(lightningRepo.updateAddressType(any(), any())).thenReturn(Result.success(Unit)) + whenever(walletRepo.refreshReceiveAddressAfterTypeChange()).thenReturn(Result.success(Unit)) whenever(settingsStore.data).thenReturn( flowOf( SettingsData( @@ -203,18 +196,19 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { sut.updateAddressType(AddressType.P2TR) advanceUntilIdle() - assertTrue(toasts.isNotEmpty()) - assertEquals(Toast.ToastType.SUCCESS, toasts.first().type) - assertEquals(settingsUpdated, toasts.first().title) + assertTrue(toasts.size >= 2) + assertEquals(Toast.ToastType.INFO, toasts.first().type) + assertEquals(applyingChanges, toasts.first().title) + assertEquals(Toast.ToastType.SUCCESS, toasts.last().type) + assertEquals(settingsUpdated, toasts.last().title) + verify(walletRepo).refreshReceiveAddressAfterTypeChange() collectJob.cancel() } @Test fun `updateAddressType failure sends error toast`() = test { - runBlocking { - wheneverBlocking { lightningRepo.updateAddressType(any(), any()) } - .thenReturn(Result.failure(Exception("Node restart failed"))) - } + whenever(lightningRepo.updateAddressType(any(), any())) + .thenReturn(Result.failure(Exception("Update failed"))) whenever(settingsStore.data).thenReturn( flowOf( SettingsData( @@ -233,9 +227,58 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(toasts.isNotEmpty()) - assertEquals(Toast.ToastType.WARNING, toasts.first().type) - assertEquals(errorTitle, toasts.first().title) - assertEquals("Node restart failed", toasts.first().description) + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(errorTitle, toasts.last().title) + assertEquals("Update failed", toasts.last().description) + verify(walletRepo, times(0)).refreshReceiveAddressAfterTypeChange() + collectJob.cancel() + } + + @Test + fun `updateAddressType no-op when same type already selected`() = test { + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.updateAddressType(AddressType.P2WPKH) // same as selected + advanceUntilIdle() + + assertTrue(toasts.isEmpty(), "No toast should be sent for no-op") + verify(lightningRepo, never()).updateAddressType(any(), any()) + collectJob.cancel() + } + + @Test + fun `setMonitoring no-op when type already in desired state`() = test { + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.setMonitoring(AddressType.P2WPKH, true) // already monitored + advanceUntilIdle() + + assertTrue(toasts.isEmpty(), "No toast should be sent for no-op") + verify(lightningRepo, never()).setMonitoring(any(), any()) collectJob.cancel() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6520c0e22..99038db01 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.github.synonymdev.ldk-node:ldk-node-android", version = "v0.7.0-rc.21" } +ldk-node-android = { module = "com.github.synonymdev.ldk-node:ldk-node-android", version = "v0.7.0-rc.25" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }