diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 95b4a12d2..998b60126 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -33,6 +33,7 @@ import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import uniffi.bitkitcore.IBtInfo import javax.inject.Inject import javax.inject.Singleton @@ -272,6 +273,11 @@ class LightningRepo @Inject constructor( description: String, expirySeconds: UInt = 86_400u ): Result = executeWhenNodeRunning("Create invoice") { + + if (coreService.shouldBlockLightning()) { + return@executeWhenNodeRunning Result.failure(ServiceError.GeoBlocked) + } + val invoice = lightningService.receive(amountSats, description, expirySeconds) Result.success(invoice) } @@ -336,6 +342,7 @@ class LightningRepo @Inject constructor( nodeStatus = getStatus(), peers = getPeers().orEmpty(), channels = getChannels().orEmpty(), + shouldBlockLightning = coreService.shouldBlockLightning() ) } } @@ -420,5 +427,6 @@ data class LightningState( val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, val peers: List = emptyList(), val channels: List = emptyList(), - val isSyncingWallet: Boolean = false + val isSyncingWallet: Boolean = false, + val shouldBlockLightning: Boolean = false ) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 7b5b701e7..0b92dbc20 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -28,6 +28,7 @@ import to.bitkit.services.CoreService import to.bitkit.utils.AddressChecker import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import uniffi.bitkitcore.Activity import uniffi.bitkitcore.ActivityFilter import uniffi.bitkitcore.PaymentType @@ -84,6 +85,12 @@ class WalletRepo @Inject constructor( suspend fun refreshBip21(): Result = withContext(bgDispatcher) { Logger.debug("Refreshing bip21", context = TAG) + if (coreService.shouldBlockLightning()) { + _walletState.update { + it.copy(receiveOnSpendingBalance = false) + } + } + //Reset invoice state _walletState.update { it.copy( @@ -283,8 +290,14 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(balanceInput = newText) } } - fun toggleReceiveOnSpendingBalance() { + suspend fun toggleReceiveOnSpendingBalance(): Result = withContext(bgDispatcher) { + if (_walletState.value.receiveOnSpendingBalance == false && coreService.shouldBlockLightning()) { + return@withContext Result.failure(ServiceError.GeoBlocked) + } + _walletState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } + + return@withContext Result.success(Unit) } fun addTagToSelected(newTag: String) { @@ -307,7 +320,6 @@ class WalletRepo @Inject constructor( suspend fun updateBip21Invoice( amountSats: ULong? = null, description: String = "", - generateBolt11IfAvailable: Boolean = true, ): Result = withContext(bgDispatcher) { try { updateBip21AmountSats(amountSats) @@ -315,7 +327,7 @@ class WalletRepo @Inject constructor( val hasChannels = lightningRepo.hasChannels() - if (hasChannels && generateBolt11IfAvailable) { + if (hasChannels && _walletState.value.receiveOnSpendingBalance) { lightningRepo.createInvoice( amountSats = _walletState.value.bip21AmountSats, description = _walletState.value.bip21Description diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 15f62d40b..2cfa9a2d2 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -12,6 +12,7 @@ import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.async.ServiceQueue import to.bitkit.env.Env import to.bitkit.ext.amountSats +import to.bitkit.models.LnPeer import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -122,6 +123,21 @@ class CoreService @Inject constructor( } } } + + suspend fun getLspPeers(): List { + val blocktankPeers = Env.trustedLnPeers + // TODO get from blocktank info when lightningService.setup sets trustedPeers0conf using BT API + // pseudocode idea: + // val blocktankPeers = getInfo(refresh = true)?.nodes?.map { LnPeer(nodeId = it.pubkey, address = "TO_DO") }.orEmpty() + return blocktankPeers + } + + suspend fun getConnectedPeers(): List = lightningService.peers.orEmpty() + + suspend fun hasExternalNode() = getConnectedPeers().any { connectedPeer -> connectedPeer !in getLspPeers() } + + //TODO this is business logic, should be moved to the domain layer in the future + suspend fun shouldBlockLightning() = checkGeoStatus() == true && !hasExternalNode() } // endregion 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 f6533e164..43dcecbf2 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 @@ -44,6 +44,7 @@ import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.ext.requiresPermission import to.bitkit.ui.LocalBalances +import to.bitkit.ui.Routes import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BalanceHeaderView @@ -105,7 +106,12 @@ fun HomeScreen( } is BottomSheetType.Receive -> { - ReceiveQrSheet(uiState) + ReceiveQrSheet( + walletState = uiState, + navigateToExternalConnection = { + rootNavController.navigate(Routes.ExternalConnection) + } + ) } is BottomSheetType.ActivityDateRangeSelector -> { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt new file mode 100644 index 000000000..87061e3b1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt @@ -0,0 +1,68 @@ +package to.bitkit.ui.screens.wallets.receive + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +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 to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors + +@Composable +fun LocationBlockScreen( + onBackPressed: () -> Unit, + navigateAdvancedSetup: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .gradientBackground() + ) { + SheetTopBar(stringResource(R.string.wallet__receive_bitcoin), onBack = onBackPressed) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f), + ) { + BodyM(text = stringResource(R.string.lightning__funding__text_blocked_cjit), color = Colors.White64) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.globe), + contentScale = ContentScale.FillWidth, + contentDescription = null, + modifier = Modifier.fillMaxWidth().padding(horizontal = 60.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton(text = stringResource(R.string.onboarding__advanced_setup), onClick = navigateAdvancedSetup) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + LocationBlockScreen( + onBackPressed = {}, + navigateAdvancedSetup = {} + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index aa39c5a3f..f55a4943b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -49,6 +49,7 @@ import to.bitkit.R import to.bitkit.ext.truncate import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodeLifecycleState.Running +import to.bitkit.repositories.LightningState import to.bitkit.ui.appViewModel import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BodyM @@ -70,6 +71,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.MainUiState +import to.bitkit.viewmodels.WalletViewModelEffects private object ReceiveRoutes { const val QR = "qr" @@ -80,10 +82,12 @@ private object ReceiveRoutes { const val LIQUIDITY_ADDITIONAL = "liquidity_additional" const val EDIT_INVOICE = "edit_invoice" const val ADD_TAG = "add_tag" + const val LOCATION_BLOCK = "location_block" } @Composable fun ReceiveQrSheet( + navigateToExternalConnection: () -> Unit, walletState: MainUiState, modifier: Modifier = Modifier, ) { @@ -96,6 +100,7 @@ fun ReceiveQrSheet( val cjitInvoice = remember { mutableStateOf(null) } val showCreateCjit = remember { mutableStateOf(false) } val cjitEntryDetails = remember { mutableStateOf(null) } + val lightningState : LightningState by wallet.lightningState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { try { @@ -122,20 +127,37 @@ fun ReceiveQrSheet( LaunchedEffect(cjitInvoice.value) { showCreateCjit.value = !cjitInvoice.value.isNullOrBlank() } + + LaunchedEffect(Unit) { + wallet.walletEffect.collect { effect -> + when(effect) { + WalletViewModelEffects.NavigateGeoBlockScreen -> { + navController.navigate(ReceiveRoutes.LOCATION_BLOCK) + } + } + } + } + ReceiveQrScreen( cjitInvoice = cjitInvoice, cjitActive = showCreateCjit, walletState = walletState, onCjitToggle = { active -> - showCreateCjit.value = active - if (!active) { - cjitInvoice.value = null - } else if (cjitInvoice.value == null) { - navController.navigate(ReceiveRoutes.AMOUNT) + when { + active && lightningState.shouldBlockLightning -> navController.navigate(ReceiveRoutes.LOCATION_BLOCK) + + !active -> { + showCreateCjit.value = false + cjitInvoice.value = null + } + active && cjitInvoice.value == null -> { + showCreateCjit.value = true + navController.navigate(ReceiveRoutes.AMOUNT) + } } }, onClickEditInvoice = { navController.navigate(ReceiveRoutes.EDIT_INVOICE) }, - onClickReceiveOnSpending = { wallet.updateReceiveOnSpending() } + onClickReceiveOnSpending = { wallet.toggleReceiveOnSpending() } ) } composable(ReceiveRoutes.AMOUNT) { @@ -147,6 +169,12 @@ fun ReceiveQrSheet( onBack = { navController.popBackStack() }, ) } + composable(ReceiveRoutes.LOCATION_BLOCK) { + LocationBlockScreen( + onBackPressed = { navController.popBackStack() }, + navigateAdvancedSetup = navigateToExternalConnection + ) + } composable(ReceiveRoutes.CONFIRM) { cjitEntryDetails.value?.let { entryDetails -> ReceiveConfirmScreen( diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 203ca9f5e..80ec1b2af 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -30,6 +30,7 @@ sealed class ServiceError(message: String) : AppError(message) { data object NodeStillRunning : ServiceError("Node is still running") data object InvalidNodeSigningMessage : ServiceError("Invalid node signing message") data object CurrencyRateUnavailable : ServiceError("Currency rate unavailable") + data object GeoBlocked : ServiceError("Geo blocked user") } sealed class KeychainError(message: String) : AppError(message) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index fc83ac0e1..7ed891a91 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -9,7 +9,9 @@ import androidx.lifecycle.viewModelScope 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.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -28,6 +30,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import javax.inject.Inject @HiltViewModel @@ -53,6 +56,10 @@ class WalletViewModel @Inject constructor( @Deprecated("Prioritize get the wallet and lightning states from LightningRepo or WalletRepo") val uiState = _uiState.asStateFlow() + private val _walletEffect = MutableSharedFlow(extraBufferCapacity = 1) + val walletEffect = _walletEffect.asSharedFlow() + private fun walletEffect(effect: WalletViewModelEffects) = viewModelScope.launch { _walletEffect.emit(effect) } + init { collectStates() } @@ -181,13 +188,11 @@ class WalletViewModel @Inject constructor( fun updateBip21Invoice( amountSats: ULong? = null, - generateBolt11IfAvailable: Boolean = true ) { viewModelScope.launch { walletRepo.updateBip21Invoice( amountSats = amountSats, description = walletState.value.bip21Description, - generateBolt11IfAvailable = generateBolt11IfAvailable, ).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -198,12 +203,23 @@ class WalletViewModel @Inject constructor( } } - fun updateReceiveOnSpending() { - walletRepo.toggleReceiveOnSpendingBalance() - updateBip21Invoice( - amountSats = walletState.value.bip21AmountSats, - generateBolt11IfAvailable = walletState.value.receiveOnSpendingBalance - ) + fun toggleReceiveOnSpending() { + viewModelScope.launch { + walletRepo.toggleReceiveOnSpendingBalance().onSuccess { + updateBip21Invoice( + amountSats = walletState.value.bip21AmountSats, + ) + }.onFailure { e -> + if (e is ServiceError.GeoBlocked) { + walletEffect(WalletViewModelEffects.NavigateGeoBlockScreen) + return@launch + } + + updateBip21Invoice( + amountSats = walletState.value.bip21AmountSats, + ) + } + } } fun refreshBip21() { @@ -424,3 +440,7 @@ data class MainUiState( val bip21Description: String = "", val selectedTags: List = listOf(), ) + +sealed interface WalletViewModelEffects { + data object NavigateGeoBlockScreen: WalletViewModelEffects +} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 572f05e47..ee9e6cc19 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.models.LnPeer @@ -44,6 +45,7 @@ class LightningRepoTest : BaseUnitTest() { @Before fun setUp() { + wheneverBlocking { coreService.shouldBlockLightning() }.thenReturn(false) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index f96e97e9f..7409ddbac 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore @@ -44,6 +45,7 @@ class WalletRepoTest : BaseUnitTest() { @Before fun setUp() { + wheneverBlocking { coreService.shouldBlockLightning() }.thenReturn(false) whenever(appStorage.onchainAddress).thenReturn("") whenever(appStorage.bolt11).thenReturn("") whenever(appStorage.bip21).thenReturn("") @@ -150,6 +152,19 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo).newAddress() } + @Test + fun `refreshBip21 should set receiveOnSpendingBalance as false if shouldBlockLightning is true`() = test { + wheneverBlocking { coreService.shouldBlockLightning() }.thenReturn(true) + whenever(sut.getOnchainAddress()).thenReturn("") + whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) + + val result = sut.refreshBip21() + + assertTrue(result.isSuccess) + assertEquals(false, sut.walletState.value.receiveOnSpendingBalance) + } + @Test fun `refreshBip21 should generate new address when current has transactions`() = test { whenever(sut.getOnchainAddress()).thenReturn("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") @@ -274,7 +289,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(lightningRepo.hasChannels()).thenReturn(true) whenever(lightningRepo.createInvoice(1000uL, description = "test")).thenReturn(Result.success(testInvoice)) - sut.updateBip21Invoice(amountSats = 1000uL, description = "test", generateBolt11IfAvailable = true).let { result -> + sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> assertTrue(result.isSuccess) assertEquals(testInvoice, sut.walletState.value.bolt11) } @@ -399,6 +414,19 @@ class WalletRepoTest : BaseUnitTest() { assertEquals(!initialValue, sut.walletState.value.receiveOnSpendingBalance) } + @Test + fun `toggleReceiveOnSpendingBalance should return failure if shouldBlockLightning is true`() = test { + wheneverBlocking { coreService.shouldBlockLightning() }.thenReturn(true) + + if (sut.walletState.value.receiveOnSpendingBalance == true) { + sut.toggleReceiveOnSpendingBalance() + } + + val result = sut.toggleReceiveOnSpendingBalance() + + assert(result.isFailure) + } + @Test fun `addTagToSelected should add tag`() = test { val testTag = "testTag" diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index a12768e39..cbc487e15 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -112,11 +112,11 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `updateBip21Invoice should call walletRepo updateBip21Invoice and send failure toast`() = test { val testError = Exception("Test error") - whenever(walletRepo.updateBip21Invoice(anyOrNull(), any(), any())).thenReturn(Result.failure(testError)) + whenever(walletRepo.updateBip21Invoice(anyOrNull(), any())).thenReturn(Result.failure(testError)) sut.updateBip21Invoice() - verify(walletRepo).updateBip21Invoice(anyOrNull(), any(), any()) + verify(walletRepo).updateBip21Invoice(anyOrNull(), any()) // Add verification for ToastEventBus.send }