diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bde1399db..e4871a803 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,6 +164,7 @@ android { buildConfigField("boolean", "TREZOR_BRIDGE", trezorBridgeEnv) buildConfigField("String", "TREZOR_BRIDGE_URL", "\"$trezorBridgeUrlEnv\"") buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true") + buildConfigField("boolean", "FEATURE_PAYKIT_UI_DISABLED", System.getenv("PAYKIT_UI_DISABLED")?.toBoolean()?.toString() ?: "false") buildConfigField("String", "LOCALES", "\"${bcp47Locales.joinToString(",")}\"") } diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 1e0e6884c..4217aef7c 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -3,8 +3,13 @@ package to.bitkit.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.dataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable import to.bitkit.data.serializers.SettingsSerializer import to.bitkit.env.Env @@ -24,13 +29,17 @@ private val Context.settingsDataStore: DataStore by dataStore( serializer = SettingsSerializer, ) +private val Context.localSettingsDataStore: DataStore by preferencesDataStore("local_settings") + @Singleton class SettingsStore @Inject constructor( @ApplicationContext private val context: Context, ) { private val store = context.settingsDataStore + private val localStore = context.localSettingsDataStore val data: Flow = store.data + val isPaykitEnabled: Flow = localStore.data.map { it[PAYKIT_ENABLED_KEY] ?: false } @Volatile var restoredMonitoredTypesFromBackup: Boolean = false @@ -53,6 +62,10 @@ class SettingsStore @Inject constructor( store.updateData(transform) } + suspend fun setIsPaykitEnabled(value: Boolean) { + localStore.edit { it[PAYKIT_ENABLED_KEY] = value } + } + suspend fun addLastUsedTag(newTag: String) { store.updateData { currentSettings -> val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct() @@ -76,6 +89,7 @@ class SettingsStore @Inject constructor( suspend fun reset() { store.updateData { SettingsData() } + localStore.edit { it.clear() } restoredMonitoredTypesFromBackup = false Logger.info("Deleted all user settings data.") } @@ -83,6 +97,7 @@ class SettingsStore @Inject constructor( companion object { private const val TAG = "SettingsStore" private const val MAX_LAST_USED_TAGS = 10 + private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled") } } @@ -103,6 +118,7 @@ data class SettingsData( val hasConfirmedPublicPaykitEndpoints: Boolean = false, val sharesPublicPaykitEndpoints: Boolean = false, val sharesPrivatePaykitEndpoints: Boolean = false, + val publicPaykitCleanupPending: Boolean = false, val publicPaykitLightningEnabled: Boolean = true, val publicPaykitOnchainEnabled: Boolean = true, val publicPaykitBolt11: String = "", @@ -148,3 +164,25 @@ fun SettingsData.resetPin() = this.copy( isPinForPaymentsEnabled = false, isBiometricEnabled = false, ) + +fun SettingsData.hasPublicPaykitPublicationState(): Boolean = + hasConfirmedPublicPaykitEndpoints || + sharesPublicPaykitEndpoints || + publicPaykitCleanupPending || + publicPaykitBolt11.isNotBlank() || + publicPaykitBolt11PaymentHash.isNotBlank() || + publicPaykitBolt11ExpiresAtMillis > 0L + +fun SettingsData.hasPaykitState(): Boolean = + hasPublicPaykitPublicationState() || + sharesPrivatePaykitEndpoints + +fun SettingsData.paykitDisabled(markPublicCleanupPending: Boolean = false) = copy( + hasConfirmedPublicPaykitEndpoints = false, + sharesPublicPaykitEndpoints = false, + sharesPrivatePaykitEndpoints = false, + publicPaykitCleanupPending = publicPaykitCleanupPending || markPublicCleanupPending, + publicPaykitBolt11 = "", + publicPaykitBolt11PaymentHash = "", + publicPaykitBolt11ExpiresAtMillis = 0, +) diff --git a/app/src/main/java/to/bitkit/flags/PaykitFeatureFlags.kt b/app/src/main/java/to/bitkit/flags/PaykitFeatureFlags.kt new file mode 100644 index 000000000..baa298cf3 --- /dev/null +++ b/app/src/main/java/to/bitkit/flags/PaykitFeatureFlags.kt @@ -0,0 +1,11 @@ +package to.bitkit.flags + +import to.bitkit.BuildConfig + +object PaykitFeatureFlags { + const val isUiAvailable = !BuildConfig.FEATURE_PAYKIT_UI_DISABLED + + fun isUiEnabled(localFlagEnabled: Boolean): Boolean { + return isUiAvailable && localFlagEnabled + } +} diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index e1e2271b1..7aa39ac7c 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -87,6 +87,18 @@ fun AddressType.toDerivationPath( } } +fun AddressType.toAccountDerivationPath(network: Network = Env.network): String { + val coinType = if (network == Network.BITCOIN) 0 else 1 + + return when (this) { + AddressType.P2TR -> "m/86'/$coinType'/0'" + AddressType.P2WPKH -> "m/84'/$coinType'/0'" + AddressType.P2SH -> "m/49'/$coinType'/0'" + AddressType.P2PKH -> "m/44'/$coinType'/0'" + else -> "" + } +} + fun AddressType.toSettingsString(): String = when (this) { AddressType.P2TR -> "taproot" AddressType.P2WPKH -> "nativeSegwit" diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 081723aec..b7ffce452 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -591,7 +591,7 @@ class BackupRepo @Inject constructor( } performRestore(BackupCategory.WALLET) { dataBytes -> restoreWalletBackup(dataBytes) - } + }.getOrThrow() performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) blocktankRepo.restoreFromBackup(parsed) @@ -621,20 +621,11 @@ class BackupRepo @Inject constructor( if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) { cacheStore.update { it.copy(onchainAddress = "", bip21 = "") } } - privatePaykitAddressReservationRepo.get() - .restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType) - .onFailure { - Logger.warn("Failed to restore private Paykit reservations", it, context = TAG) - } - privatePaykitRepo.get().restoreBackup(parsed.privatePaykitContactLinks) - .onFailure { - Logger.warn("Failed to restore private Paykit contact links", it, context = TAG) - } - privatePaykitAddressReservationRepo.get() - .reconcileReservedIndexesWithLdk() - .onFailure { - Logger.warn("Failed to reconcile restored private Paykit reservations", it, context = TAG) - } + val addressReservationRepo = privatePaykitAddressReservationRepo.get() + addressReservationRepo.restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType).getOrThrow() + val privateRepo = privatePaykitRepo.get() + privateRepo.restoreBackup(parsed.privatePaykitContactLinks).getOrThrow() + addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) return parsed.createdAt } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 5b51e5085..48b61bdeb 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -30,6 +31,7 @@ import kotlinx.coroutines.withContext import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore +import to.bitkit.data.hasPublicPaykitPublicationState import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env @@ -983,7 +985,8 @@ class PubkyRepo @Inject constructor( // region Sign out suspend fun signOut(): Result { - removeBitkitPaymentEndpoints() + val hadPublicPaykitState = settingsStore.data.first().hasPublicPaykitPublicationState() + val endpointCleanupResult = removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } val result = runCatching { @@ -993,7 +996,7 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { pubkyService.forceSignOut() } } - clearLocalState() + clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPublicPaykitState) return result } @@ -1074,16 +1077,16 @@ class PubkyRepo @Inject constructor( _contactsLoadVersion.update { it + 1 } } - private suspend fun clearLocalState() = withContext(ioDispatcher) { + private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) { runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - runCatching { clearPublicPaykitSharingState() } + runCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) } .onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) } notifyBackupStateChanged() clearAuthenticatedState() } - private suspend fun clearPublicPaykitSharingState() { + private suspend fun clearPublicPaykitSharingState(publicPaykitCleanupPending: Boolean) { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = false, @@ -1091,6 +1094,7 @@ class PubkyRepo @Inject constructor( publicPaykitBolt11 = "", publicPaykitBolt11PaymentHash = "", publicPaykitBolt11ExpiresAtMillis = 0, + publicPaykitCleanupPending = publicPaykitCleanupPending, ) } } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 2e92afccb..3e7e41ff0 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -179,11 +179,13 @@ class PublicPaykitRepo @Inject constructor( runCatching { if (!publish) { removePublishedEndpoints() + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } return@runCatching } val desired = buildWalletEndpoints(refresh = true) applyPublishedEndpoints(desired) + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } } @@ -198,6 +200,7 @@ class PublicPaykitRepo @Inject constructor( requireEndpoint = requireEndpoint, ) applyPublishedEndpoints(desired) + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 58fb3fbb0..da07c1687 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -25,6 +25,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex @@ -33,12 +34,15 @@ import to.bitkit.models.AddressModel 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.toDerivationPath +import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import to.bitkit.utils.measured import javax.inject.Inject import javax.inject.Singleton @@ -401,7 +405,14 @@ class WalletRepo @Inject constructor( isChange = isChange, startIndex = startIndex, count = count, - ).getOrThrow() + ).getOrElse { + deriveAddressInfosFromMnemonic( + addressType = addressType, + isChange = isChange, + startIndex = startIndex, + count = count, + ) + } val addresses = result.map { address -> AddressModel( @@ -417,6 +428,33 @@ class WalletRepo @Inject constructor( } } + private suspend fun deriveAddressInfosFromMnemonic( + addressType: AddressType, + isChange: Boolean, + startIndex: Int, + count: Int, + ): List { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val baseDerivationPath = addressType.toAccountDerivationPath() + + return coreService.onchain.deriveBitcoinAddresses( + mnemonicPhrase = mnemonic, + derivationPathStr = baseDerivationPath, + network = Env.network, + bip39Passphrase = passphrase, + isChange = isChange, + startIndex = startIndex.toUInt(), + count = count.toUInt(), + ).addresses.mapIndexed { offset, address -> + AddressDerivationInfo( + address = address.address, + index = startIndex + offset, + ) + } + } + fun getBolt11(): String = _walletState.value.bolt11 suspend fun setBolt11(bolt11: String) { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4d28e3dc9..167ea4f32 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -60,6 +60,7 @@ import to.bitkit.ui.onboarding.InitializingWalletView import to.bitkit.ui.onboarding.WalletRestoreErrorView import to.bitkit.ui.onboarding.WalletRestoreSuccessView import to.bitkit.ui.screens.CriticalUpdateScreen +import to.bitkit.ui.screens.common.ComingSoonScreen import to.bitkit.ui.screens.contacts.AddContactScreen import to.bitkit.ui.screens.contacts.AddContactViewModel import to.bitkit.ui.screens.contacts.ContactActivityScreen @@ -395,6 +396,7 @@ fun ContentView( val hasSeenContactsIntro by settingsViewModel.hasSeenContactsIntro.collectAsStateWithLifecycle() val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() val hasPubkyContacts by settingsViewModel.hasPubkyContacts.collectAsStateWithLifecycle() + val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() Box( @@ -556,6 +558,7 @@ fun ContentView( hasSeenContactsIntro = hasSeenContactsIntro, hasContacts = hasPubkyContacts, isProfileAuthenticated = isProfileAuthenticated, + isPaykitEnabled = isPaykitEnabled, modifier = Modifier.align(Alignment.TopEnd) ) } @@ -594,7 +597,7 @@ private fun RootNavHost( contacts(navController, settingsViewModel, appViewModel) profile(navController, settingsViewModel) shop(navController, settingsViewModel, appViewModel) - generalSettingsSubScreens(navController) + generalSettingsSubScreens(navController, settingsViewModel) advancedSettingsSubScreens(navController) transactionSpeedSettings(navController) pinManagement(navController) @@ -606,7 +609,7 @@ private fun RootNavHost( orderDetailSettings(navController) cjitDetailSettings(navController) lightningConnections(navController) - activityItem(activityListViewModel, navController) + activityItem(activityListViewModel, navController, settingsViewModel) authCheck(navController) logs(navController) suggestions(navController) @@ -977,6 +980,30 @@ private fun NavGraphBuilder.settings( } } +@Composable +private fun PaykitRouteGuard( + settingsViewModel: SettingsViewModel, + navController: NavHostController, + redirectWhenDisabled: Boolean = true, + disabledContent: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() + val isPaykitStateLoaded by settingsViewModel.isPaykitStateLoaded.collectAsStateWithLifecycle() + + if (!isPaykitStateLoaded) return + + if (isPaykitEnabled) { + content() + } else if (redirectWhenDisabled) { + LaunchedEffect(Unit) { + navController.navigateToHome() + } + } else { + disabledContent() + } +} + @Suppress("LongMethod") private fun NavGraphBuilder.contacts( navController: NavHostController, @@ -984,102 +1011,128 @@ private fun NavGraphBuilder.contacts( appViewModel: AppViewModel, ) { composableWithDefaultTransitions { backStackEntry -> - val route = backStackEntry.toRoute() - val viewModel: ContactsViewModel = hiltViewModel() - ContactsScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onClickMyProfile = { navController.navigateTo(Routes.Profile) }, - onClickContact = { navController.navigateTo(Routes.ContactDetail(it)) }, - onAddContact = { navController.navigateTo(Routes.AddContact(it)) }, - onScanQr = { - appViewModel.showScannerSheet { scannedData -> - navController.navigateTo(Routes.AddContact(scannedData)) - } + PaykitRouteGuard( + settingsViewModel = settingsViewModel, + navController = navController, + redirectWhenDisabled = false, + disabledContent = { + ComingSoonScreen( + onWalletOverviewClick = { navController.navigateToHome() }, + onBackClick = { navController.popBackStack() } + ) }, - openAddContactSheet = route.showAddContactSheet, - ) + ) { + val route = backStackEntry.toRoute() + val viewModel: ContactsViewModel = hiltViewModel() + ContactsScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onClickMyProfile = { navController.navigateTo(Routes.Profile) }, + onClickContact = { navController.navigateTo(Routes.ContactDetail(it)) }, + onAddContact = { navController.navigateTo(Routes.AddContact(it)) }, + onScanQr = { + appViewModel.showScannerSheet { scannedData -> + navController.navigateTo(Routes.AddContact(scannedData)) + } + }, + openAddContactSheet = route.showAddContactSheet, + ) + } } composableWithDefaultTransitions { - val isAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() - val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() - ContactsIntroScreen( - onContinue = { - settingsViewModel.setHasSeenContactsIntro(true) - when { - isAuthenticated -> navController.navigateTo( - Routes.Contacts(showAddContactSheet = true) - ) { popUpTo(Routes.Home) } - - hasSeenProfileIntro -> navController.navigateTo(Routes.PubkyChoice) { popUpTo(Routes.Home) } - else -> navController.navigateTo(Routes.ProfileIntro) { popUpTo(Routes.Home) } - } - }, - onBackClick = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val isAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() + val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() + ContactsIntroScreen( + onContinue = { + settingsViewModel.setHasSeenContactsIntro(true) + when { + isAuthenticated -> navController.navigateTo( + Routes.Contacts(showAddContactSheet = true) + ) { popUpTo(Routes.Home) } + + hasSeenProfileIntro -> navController.navigateTo(Routes.PubkyChoice) { popUpTo(Routes.Home) } + else -> navController.navigateTo(Routes.ProfileIntro) { popUpTo(Routes.Home) } + } + }, + onBackClick = { navController.popBackStack() }, + ) + } } composableWithDefaultTransitions { - val viewModel: ContactDetailViewModel = hiltViewModel() - ContactDetailScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onPayContact = { paymentRequest, publicKey -> - appViewModel.openContactPayment(paymentRequest, publicKey) - }, - onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, - onEditContact = { navController.navigateTo(Routes.EditContact(it)) }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: ContactDetailViewModel = hiltViewModel() + ContactDetailScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onPayContact = { paymentRequest, publicKey -> + appViewModel.openContactPayment(paymentRequest, publicKey) + }, + onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, + onEditContact = { navController.navigateTo(Routes.EditContact(it)) }, + ) + } } composableWithDefaultTransitions { - val viewModel: ContactActivityViewModel = hiltViewModel() - ContactActivityScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: ContactActivityViewModel = hiltViewModel() + ContactActivityScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, + ) + } } composableWithDefaultTransitions { - val viewModel: AddContactViewModel = hiltViewModel() - AddContactScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onContactSaved = { navController.popBackStack() }, - onPayContact = { paymentRequest, publicKey -> - navController.popBackStack() - appViewModel.openContactPayment(paymentRequest, publicKey) - }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: AddContactViewModel = hiltViewModel() + AddContactScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onContactSaved = { navController.popBackStack() }, + onPayContact = { paymentRequest, publicKey -> + navController.popBackStack() + appViewModel.openContactPayment(paymentRequest, publicKey) + }, + ) + } } composableWithDefaultTransitions { - val viewModel: EditContactViewModel = hiltViewModel() - EditContactScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onContactDeleted = { - navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) } - }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: EditContactViewModel = hiltViewModel() + EditContactScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onContactDeleted = { + navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) } + }, + ) + } } composableWithDefaultTransitions { - val viewModel: ContactImportOverviewViewModel = hiltViewModel() - ContactImportOverviewScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onNavigateToSelect = { navController.navigateTo(Routes.ContactImportSelect) }, - onImportComplete = { - navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } - }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: ContactImportOverviewViewModel = hiltViewModel() + ContactImportOverviewScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onNavigateToSelect = { navController.navigateTo(Routes.ContactImportSelect) }, + onImportComplete = { + navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } + }, + ) + } } composableWithDefaultTransitions { - val viewModel: ContactImportSelectViewModel = hiltViewModel() - ContactImportSelectScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onImportComplete = { - navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } - }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: ContactImportSelectViewModel = hiltViewModel() + ContactImportSelectScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onImportComplete = { + navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } + }, + ) + } } } @@ -1089,71 +1142,93 @@ private fun NavGraphBuilder.profile( settingsViewModel: SettingsViewModel, ) { composableWithDefaultTransitions { - val viewModel: ProfileViewModel = hiltViewModel() - ProfileScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onEditProfile = { navController.navigateTo(Routes.EditProfile) }, - ) + PaykitRouteGuard( + settingsViewModel = settingsViewModel, + navController = navController, + redirectWhenDisabled = false, + disabledContent = { + ComingSoonScreen( + onWalletOverviewClick = { navController.navigateToHome() }, + onBackClick = { navController.popBackStack() } + ) + }, + ) { + val viewModel: ProfileViewModel = hiltViewModel() + ProfileScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onEditProfile = { navController.navigateTo(Routes.EditProfile) }, + ) + } } composableWithDefaultTransitions { - ProfileIntroScreen( - onContinue = { - settingsViewModel.setHasSeenProfileIntro(true) - navController.navigateTo(Routes.PubkyChoice) - }, - onBackClick = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + ProfileIntroScreen( + onContinue = { + settingsViewModel.setHasSeenProfileIntro(true) + navController.navigateTo(Routes.PubkyChoice) + }, + onBackClick = { navController.popBackStack() }, + ) + } } composableWithDefaultTransitions { - val viewModel: PubkyChoiceViewModel = hiltViewModel() - PubkyChoiceScreen( - viewModel = viewModel, - onNavigateToCreateProfile = { navController.navigateTo(Routes.CreateProfile) }, - onNavigateToContactImportOverview = { - navController.navigateTo(Routes.ContactImportOverview) { popUpTo(Routes.Home) } - }, - onNavigateToPayContacts = { - navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } - }, - onBackClick = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: PubkyChoiceViewModel = hiltViewModel() + PubkyChoiceScreen( + viewModel = viewModel, + onNavigateToCreateProfile = { navController.navigateTo(Routes.CreateProfile) }, + onNavigateToContactImportOverview = { + navController.navigateTo(Routes.ContactImportOverview) { popUpTo(Routes.Home) } + }, + onNavigateToPayContacts = { + navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } + }, + onBackClick = { navController.popBackStack() }, + ) + } } composableWithDefaultTransitions { - val viewModel: CreateProfileViewModel = hiltViewModel() - CreateProfileScreen( - viewModel = viewModel, - onNavigateToPayContacts = { - navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } - }, - onBackClick = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: CreateProfileViewModel = hiltViewModel() + CreateProfileScreen( + viewModel = viewModel, + onNavigateToPayContacts = { + navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } + }, + onBackClick = { navController.popBackStack() }, + ) + } } composableWithDefaultTransitions { - val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() - val viewModel: EditProfileViewModel = hiltViewModel() - EditProfileScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStack() }, - onExitProfile = { - val nextRoute = if (hasSeenProfileIntro) { - Routes.PubkyChoice - } else { - Routes.ProfileIntro - } - navController.navigateTo(nextRoute) { popUpTo(Routes.Home) } - }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() + val viewModel: EditProfileViewModel = hiltViewModel() + EditProfileScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onExitProfile = { + val nextRoute = if (hasSeenProfileIntro) { + Routes.PubkyChoice + } else { + Routes.ProfileIntro + } + navController.navigateTo(nextRoute) { popUpTo(Routes.Home) } + }, + ) + } } composableWithDefaultTransitions { - val viewModel: PayContactsViewModel = hiltViewModel() - PayContactsScreen( - viewModel = viewModel, - onContinue = { - navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) } - }, - onBackClick = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val viewModel: PayContactsViewModel = hiltViewModel() + PayContactsScreen( + viewModel = viewModel, + onContinue = { + navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) } + }, + onBackClick = { navController.popBackStack() }, + ) + } } } @@ -1194,7 +1269,10 @@ private fun NavGraphBuilder.shop( } } -private fun NavGraphBuilder.generalSettingsSubScreens(navController: NavHostController) { +private fun NavGraphBuilder.generalSettingsSubScreens( + navController: NavHostController, + settingsViewModel: SettingsViewModel, +) { composableWithDefaultTransitions { WidgetsSettingsScreen(navController) } @@ -1208,9 +1286,11 @@ private fun NavGraphBuilder.generalSettingsSubScreens(navController: NavHostCont ) } composableWithDefaultTransitions { - PaymentPreferenceScreen( - onBack = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + PaymentPreferenceScreen( + onBack = { navController.popBackStack() }, + ) + } } composableWithDefaultTransitions { @@ -1359,6 +1439,7 @@ private fun NavGraphBuilder.lightningConnections( private fun NavGraphBuilder.activityItem( activityListViewModel: ActivityListViewModel, navController: NavHostController, + settingsViewModel: SettingsViewModel, ) { composableWithDefaultTransitions { ActivityDetailScreen( @@ -1374,11 +1455,13 @@ private fun NavGraphBuilder.activityItem( ) } composableWithDefaultTransitions { - val route = it.toRoute() - ActivityAssignContactScreen( - activityId = route.id, - onBackClick = { navController.popBackStack() }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + val route = it.toRoute() + ActivityAssignContactScreen( + activityId = route.id, + onBackClick = { navController.popBackStack() }, + ) + } } composableWithDefaultTransitions { ActivityExploreScreen( diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index d3849e414..a050a84ac 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -75,6 +75,7 @@ fun DrawerMenu( hasSeenContactsIntro: Boolean = false, hasContacts: Boolean = false, isProfileAuthenticated: Boolean = false, + isPaykitEnabled: Boolean = false, ) { val scope = rememberCoroutineScope() @@ -128,6 +129,11 @@ fun DrawerMenu( }, onClickContacts = { when { + !isPaykitEnabled -> { + onBeforeNavigate(Routes.Contacts()) + rootNavController.navigateIfNotCurrent(Routes.Contacts()) + } + !hasSeenContactsIntro && !hasContacts -> { onBeforeNavigate(Routes.ContactsIntro) rootNavController.navigateIfNotCurrent(Routes.ContactsIntro) @@ -150,17 +156,22 @@ fun DrawerMenu( } }, onClickProfile = { - onBeforeNavigate( - when { - isProfileAuthenticated -> Routes.Profile - hasSeenProfileIntro -> Routes.PubkyChoice - else -> Routes.ProfileIntro - } - ) - rootNavController.navigateToProfile( - isAuthenticated = isProfileAuthenticated, - hasSeenIntro = hasSeenProfileIntro, - ) + if (!isPaykitEnabled) { + onBeforeNavigate(Routes.Profile) + rootNavController.navigateIfNotCurrent(Routes.Profile) + } else { + onBeforeNavigate( + when { + isProfileAuthenticated -> Routes.Profile + hasSeenProfileIntro -> Routes.PubkyChoice + else -> Routes.ProfileIntro + } + ) + rootNavController.navigateToProfile( + isAuthenticated = isProfileAuthenticated, + hasSeenIntro = hasSeenProfileIntro, + ) + } }, onBeforeNavigate = onBeforeNavigate, ) diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt index 8012049e0..343168d7b 100644 --- a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -38,6 +39,7 @@ fun SettingsSwitchRow( subtitle: String? = null, iconRes: Int? = null, iconTint: Color = Color.Unspecified, + switchTestTag: String? = null, colors: SwitchColors = AppSwitchDefaults.colors ) { SettingsSwitchRowCore( @@ -47,6 +49,7 @@ fun SettingsSwitchRow( enabled = enabled, subtitle = subtitle, colors = colors, + switchTestTag = switchTestTag, icon = if (iconRes != null) { { Icon( @@ -73,6 +76,7 @@ fun SettingsSwitchRow( modifier: Modifier = Modifier, enabled: Boolean = true, subtitle: String? = null, + switchTestTag: String? = null, colors: SwitchColors = AppSwitchDefaults.colors ) { SettingsSwitchRowCore( @@ -82,6 +86,7 @@ fun SettingsSwitchRow( enabled = enabled, subtitle = subtitle, colors = colors, + switchTestTag = switchTestTag, icon = { icon() HorizontalSpacer(8.dp) @@ -99,6 +104,7 @@ private fun SettingsSwitchRowCore( enabled: Boolean = true, subtitle: String? = null, icon: (@Composable () -> Unit)? = null, + switchTestTag: String? = null, colors: SwitchColors = AppSwitchDefaults.colors ) { Column(modifier = modifier) { @@ -131,6 +137,7 @@ private fun SettingsSwitchRowCore( onCheckedChange = null, // handled by parent enabled = enabled, colors = colors, + modifier = switchTestTag?.let { Modifier.testTag(it) } ?: Modifier ) } HorizontalDivider(color = Colors.White10) diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index c329850f0..d370d4ae1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -4,22 +4,32 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import org.lightningdevkit.ldknode.Network import to.bitkit.R import to.bitkit.env.Env +import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.components.settings.SettingsTextButtonRow import to.bitkit.ui.navigateTo import to.bitkit.ui.scaffold.AppTopBar @@ -38,6 +48,8 @@ fun DevSettingsScreen( val activity = activityListViewModel ?: return val settings = settingsViewModel ?: return val context = LocalContext.current + val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() + var showPaykitWarning by remember { mutableStateOf(false) } ScreenColumn { AppTopBar( @@ -56,6 +68,27 @@ fun DevSettingsScreen( SettingsButtonRow("VSS") { navController.navigateTo(Routes.VssDebug) } SettingsButtonRow("Probing Tool") { navController.navigateTo(Routes.ProbingTool) } + if (PaykitFeatureFlags.isUiAvailable) { + SectionHeader("PAYKIT") + SettingsSwitchRow( + title = "Enable Paykit UI", + isChecked = isPaykitEnabled, + onClick = { + if (isPaykitEnabled) { + settings.setIsPaykitEnabled(false) + app.toast( + type = Toast.ToastType.SUCCESS, + title = "Paykit UI disabled", + testTag = "PaykitUiDisabledToast", + ) + } else { + showPaykitWarning = true + } + }, + switchTestTag = "PaykitUiToggle", + ) + } + SectionHeader("HARDWARE WALLET") SettingsButtonRow("Trezor") { navController.navigateTo(Routes.Trezor) } @@ -184,4 +217,37 @@ fun DevSettingsScreen( ) } } + + if (showPaykitWarning && PaykitFeatureFlags.isUiAvailable) { + AlertDialog( + onDismissRequest = { showPaykitWarning = false }, + title = { Text("Enable Paykit UI?") }, + text = { + Text( + "Paykit features are still experimental and may not work reliably until supporting homeserver " + + "changes are deployed." + ) + }, + confirmButton = { + TextButton( + onClick = { + settings.setIsPaykitEnabled(true) + showPaykitWarning = false + app.toast( + type = Toast.ToastType.SUCCESS, + title = "Paykit UI enabled", + testTag = "PaykitUiEnabledToast", + ) + }, + ) { + Text("Enable") + } + }, + dismissButton = { + TextButton(onClick = { showPaykitWarning = false }) { + Text("Cancel") + } + }, + ) + } } 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 59d819201..a737bbd37 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 @@ -193,6 +193,7 @@ fun HomeScreen( val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() val isPubkyAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() + val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() val profileDisplayName by homeViewModel.profileDisplayName.collectAsStateWithLifecycle() val profileDisplayImageUri by homeViewModel.profileDisplayImageUri.collectAsStateWithLifecycle() val hasSeenWidgetsIntro: Boolean by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() @@ -235,6 +236,7 @@ fun HomeScreen( drawerState = drawerState, profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, + showProfileButton = isPaykitEnabled, onClickProfile = navigateToProfile, latestActivities = latestActivities, onRefresh = { @@ -280,7 +282,9 @@ fun HomeScreen( ) } - Suggestion.PROFILE -> navigateToProfile() + Suggestion.PROFILE -> { + if (isPaykitEnabled) navigateToProfile() else rootNavController.navigateTo(Routes.Profile) + } Suggestion.SHOP -> { if (!hasSeenShopIntro) { @@ -354,6 +358,7 @@ private fun Content( drawerState: DrawerState, profileDisplayName: String? = null, profileDisplayImageUri: String? = null, + showProfileButton: Boolean = false, onClickProfile: () -> Unit = {}, latestActivities: ImmutableList?, onRefresh: () -> Unit = {}, @@ -426,6 +431,7 @@ private fun Content( hazeState = hazeState, profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, + showProfileButton = showProfileButton, onClickProfile = { dismissKeyboard { onClickProfile() @@ -1036,6 +1042,7 @@ private fun TopBar( hazeState: HazeState, profileDisplayName: String? = null, profileDisplayImageUri: String? = null, + showProfileButton: Boolean = false, onClickProfile: () -> Unit = {}, showEditWidgets: Boolean = false, isEditingWidgets: Boolean = false, @@ -1054,11 +1061,13 @@ private fun TopBar( ) { TopAppBar( title = { - ProfileButton( - displayName = profileDisplayName, - displayImageUri = profileDisplayImageUri, - onClick = onClickProfile, - ) + if (showProfileButton) { + ProfileButton( + displayName = profileDisplayName, + displayImageUri = profileDisplayImageUri, + onClick = onClickProfile, + ) + } }, actions = { AnimatedVisibility(showEditWidgets) { 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 21b8caf9d..88e4c8965 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 @@ -322,7 +322,9 @@ class HomeViewModel @Inject constructor( else -> emptyWalletSuggestions(settings, transfers, profileAuthenticated) } val dismissedList = settings.dismissedSuggestions.mapNotNull { it.toSuggestionOrNull() } - baseSuggestions.filterNot { it in dismissedList }.take(MAX_SUGGESTIONS) + baseSuggestions + .filterNot { it in dismissedList } + .take(MAX_SUGGESTIONS) } private fun spendingSuggestions( 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 ea4778aed..c8b6223a5 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 @@ -181,6 +181,7 @@ fun ActivityDetailScreen( val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() + val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val copyToastTitle = stringResource(R.string.common__copied) val tags by detailViewModel.tags.collectAsStateWithLifecycle() @@ -212,7 +213,7 @@ fun ActivityDetailScreen( } val context = LocalContext.current - val assignedContact = assignedContactProfile(item, contacts) + val assignedContact = if (isPaykitEnabled) assignedContactProfile(item, contacts) else null val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } @@ -245,6 +246,7 @@ fun ActivityDetailScreen( onChannelClick = onChannelClick, detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, + showContactActions = isPaykitEnabled, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -326,6 +328,7 @@ private fun ActivityDetailContent( onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, + showContactActions: Boolean = true, boostTxDoesExist: ImmutableMap = persistentMapOf(), onCopy: (String) -> Unit, hideBalance: Boolean = false, @@ -542,7 +545,7 @@ private fun ActivityDetailContent( } ContactTagsSection( - contact = assignedContact, + contact = assignedContact.takeIf { showContactActions }, tags = tags, onRemoveTag = onRemoveTag, ) @@ -596,29 +599,31 @@ private fun ActivityDetailContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - PrimaryButton( - text = stringResource( - if (assignedContact != null) { - R.string.wallet__activity_detach - } else { - R.string.wallet__activity_assign - } - ), - size = ButtonSize.Small, - onClick = if (assignedContact != null) onDetachClick else onAssignClick, - enabled = !isSelfSend, - icon = { - Icon( - painter = painterResource( - if (assignedContact != null) R.drawable.ic_user_minus else R.drawable.ic_user_plus - ), - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier.weight(1f) - ) + if (showContactActions) { + PrimaryButton( + text = stringResource( + if (assignedContact != null) { + R.string.wallet__activity_detach + } else { + R.string.wallet__activity_assign + } + ), + size = ButtonSize.Small, + onClick = if (assignedContact != null) onDetachClick else onAssignClick, + enabled = !isSelfSend, + icon = { + Icon( + painter = painterResource( + if (assignedContact != null) R.drawable.ic_user_minus else R.drawable.ic_user_plus + ), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.weight(1f) + ) + } PrimaryButton( text = stringResource(R.string.wallet__activity_tag), size = ButtonSize.Small, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 04739567c..26bff26df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -266,6 +266,7 @@ fun SendRecipientScreen( onClickManual = { onEvent(SendEvent.EnterManually) }, cameraPermissionGranted = cameraPermissionState.status.isGranted, onRequestPermission = { context.startActivityAppSettings() }, + showContactOption = true, modifier = modifier, ) } @@ -281,6 +282,7 @@ private fun SendRecipientContent( cameraPermissionGranted: Boolean, onRequestPermission: () -> Unit, modifier: Modifier = Modifier, + showContactOption: Boolean = false, ) { Column( modifier = modifier @@ -317,13 +319,15 @@ private fun SendRecipientContent( } } - RectangleButton( - label = stringResource(R.string.wallet__recipient_contact), - icon = R.drawable.ic_users, - iconTint = Colors.Brand, - modifier = Modifier.testTag("RecipientContact") - ) { - onClickContact() + if (showContactOption) { + RectangleButton( + label = stringResource(R.string.wallet__recipient_contact), + icon = R.drawable.ic_users, + iconTint = Colors.Brand, + modifier = Modifier.testTag("RecipientContact") + ) { + onClickContact() + } } RectangleButton( diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index 67e3f3ffe..329674c2a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -91,6 +91,7 @@ fun SettingsScreen( val bgPaymentsIntroSeen by settings.bgPaymentsIntroSeen.collectAsStateWithLifecycle() val notificationsGranted by settings.notificationsGranted.collectAsStateWithLifecycle() val isPubkyAuthenticated by settings.isPubkyAuthenticated.collectAsStateWithLifecycle() + val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val languageUiState by languageViewModel.uiState.collectAsStateWithLifecycle() // Security tab state @@ -126,6 +127,7 @@ fun SettingsScreen( isQuickPayEnabled = isQuickPayEnabled, notificationsGranted = notificationsGranted, isPubkyAuthenticated = isPubkyAuthenticated, + isPaykitEnabled = isPaykitEnabled, ), securityState = SecurityTabState( isPinEnabled = isPinEnabled, @@ -325,7 +327,7 @@ private fun GeneralTabContent( onClick = { onEvent(SettingsEvent.TransactionSpeedClick) }, modifier = Modifier.testTag("TransactionSpeedSettings") ) - if (state.isPubkyAuthenticated) { + if (state.isPaykitEnabled && state.isPubkyAuthenticated) { SettingsButtonRow( title = stringResource(R.string.settings__payment_pref_title), icon = { SettingsIcon(R.drawable.ic_coins) }, @@ -674,6 +676,7 @@ data class GeneralTabState( val isQuickPayEnabled: Boolean = false, val notificationsGranted: Boolean = false, val isPubkyAuthenticated: Boolean = false, + val isPaykitEnabled: Boolean = false, ) @Immutable diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index f887782f9..24ca5fb22 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -25,9 +25,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer +import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.PubkyRepo @@ -41,6 +43,7 @@ class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, pubkyRepo: PubkyRepo, + settingsStore: SettingsStore, ) : ViewModel() { private val _filteredActivities = MutableStateFlow?>(null) val filteredActivities = _filteredActivities.asStateFlow() @@ -55,7 +58,12 @@ class ActivityListViewModel @Inject constructor( val latestActivities = _latestActivities.asStateFlow() val contacts: StateFlow> = - pubkyRepo.contacts.map { it.toImmutableList() }.stateInScope(persistentListOf()) + combine( + pubkyRepo.contacts, + settingsStore.isPaykitEnabled.map { PaykitFeatureFlags.isUiEnabled(it) }, + ) { contacts, isPaykitEnabled -> + if (isPaykitEnabled) contacts.toImmutableList() else persistentListOf() + }.stateInScope(persistentListOf()) val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ea780376b..83ac9750a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -95,6 +95,7 @@ import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue import to.bitkit.ext.watchUntil +import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.FeeRate import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -260,6 +261,9 @@ class AppViewModel @Inject constructor( private var isCompletingMigration = false private var addressValidationJob: Job? = null private var lastPrivatePaykitContactKeys: Set = emptySet() + private val isPaykitEnabled = settingsStore.isPaykitEnabled + .map { PaykitFeatureFlags.isUiEnabled(it) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value @@ -437,8 +441,8 @@ class AppViewModel @Inject constructor( } private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) { - val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints - if (!shouldPublish) return + val settings = settingsStore.data.first() + if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints) return val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return @@ -453,18 +457,24 @@ class AppViewModel @Inject constructor( pubkyRepo.publicKey, pubkyRepo.contacts, pubkyRepo.contactsLoadVersion, - ) { publicKey, contacts, contactsLoadVersion -> - Triple(publicKey, contacts.map { it.publicKey }.toSet(), contactsLoadVersion > 0L) + settingsStore.isPaykitEnabled.map { PaykitFeatureFlags.isUiEnabled(it) }, + ) { publicKey, contacts, contactsLoadVersion, isPaykitEnabled -> + PaykitContactSyncState( + publicKey = publicKey, + contactKeys = contacts.map { it.publicKey }.toSet(), + contactsLoaded = contactsLoadVersion > 0L, + isPaykitEnabled = isPaykitEnabled, + ) } .distinctUntilChanged() - .collect { (publicKey, contactKeys, contactsLoaded) -> - if (publicKey == null) { + .collect { state -> + if (!state.isPaykitEnabled || state.publicKey == null) { lastPrivatePaykitContactKeys = emptySet() return@collect } - if (!contactsLoaded) return@collect + if (!state.contactsLoaded) return@collect - val removedKeys = lastPrivatePaykitContactKeys - contactKeys + val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys removedKeys.forEach { privatePaykitRepo.removeSavedContact(it) .onFailure { error -> @@ -476,15 +486,15 @@ class AppViewModel @Inject constructor( } } - privatePaykitRepo.prepareSavedContacts(contactKeys) + privatePaykitRepo.prepareSavedContacts(state.contactKeys) .onFailure { Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) } - privatePaykitRepo.pruneUnsavedContactState(contactKeys) + privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } - lastPrivatePaykitContactKeys = contactKeys + lastPrivatePaykitContactKeys = state.contactKeys } } } @@ -493,16 +503,38 @@ class AppViewModel @Inject constructor( reason: String, forceRefreshLightning: Boolean = false, ) { + val contactKeys = pubkyRepo.contacts.value.map { it.publicKey } + retryPendingPaykitEndpointRemoval(contactKeys, reason) + + if (!isPaykitEnabled.value) return + privatePaykitRepo.reconcileReservedReceiveIndexes() .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } - val contactKeys = pubkyRepo.contacts.value.map { it.publicKey } + privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + } + + private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { + val settings = settingsStore.data.first() + if (settings.publicPaykitCleanupPending) { + if (settings.sharesPublicPaykitEndpoints) { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } else { + publicPaykitRepo.syncPublishedEndpoints(publish = false) + .onSuccess { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } + .onFailure { + Logger.warn("Failed to retry public Paykit endpoint removal for '$reason'", it, context = TAG) + } + } + } + privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) .onFailure { Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) } - privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) } @Suppress("CyclomaticComplexMethod") @@ -1055,7 +1087,9 @@ class AppViewModel @Inject constructor( SendEvent.ClearPayConfirmation -> _sendUiState.update { s -> s.copy(shouldConfirmPay = false) } SendEvent.BackToAmount -> setSendEffect(SendEffect.PopBack(SendRoute.Amount)) SendEvent.NavToAddress -> setSendEffect(SendEffect.NavigateToAddress) - SendEvent.Contacts -> setSendEffect(SendEffect.NavigateToContacts) + SendEvent.Contacts -> setSendEffect( + if (isPaykitEnabled.value) SendEffect.NavigateToContacts else SendEffect.NavigateToComingSoon + ) } } } @@ -1097,7 +1131,9 @@ class AppViewModel @Inject constructor( if (valueWithoutSpaces.isEmpty()) return if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) { - _sendUiState.update { it.copy(isAddressInputValid = true) } + if (isPaykitEnabled.value) { + _sendUiState.update { it.copy(isAddressInputValid = true) } + } return } @@ -1577,15 +1613,25 @@ class AppViewModel @Inject constructor( if (input.startsWith("$PUBKYAUTH_SCHEME://")) { clearActiveContactPaymentContext() - handlePubkyAuth(input) + if (isPaykitEnabled.value) { + handlePubkyAuth(input) + } else { + hideSheet() + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.other__scan__error__generic), + ) + } return@withContext } - if (routePubkyKeys) { + if (routePubkyKeys && isPaykitEnabled.value) { val route = resolvePastedPubkyRoute( input = input, ownPublicKey = pubkyRepo.publicKey.value, contacts = pubkyRepo.contacts.value, + isPaykitEnabled = isPaykitEnabled.value, ) if (route != null) { @@ -2905,11 +2951,13 @@ class AppViewModel @Inject constructor( } PubkyRingAuthCallback.parse(uri)?.let { + if (!isPaykitEnabled.value) return@launch handlePubkyRingAuthCallback(it) return@launch } if (uri.scheme == PUBKYAUTH_SCHEME) { + if (!isPaykitEnabled.value) return@launch handlePubkyAuth(uri.toString()) return@launch } @@ -3059,6 +3107,13 @@ enum class SendMethod { ONCHAIN, LIGHTNING } data class ContactPaymentContext(val publicKey: String) +private data class PaykitContactSyncState( + val publicKey: String?, + val contactKeys: Set, + val contactsLoaded: Boolean, + val isPaykitEnabled: Boolean, +) + sealed class SendEffect { data class PopBack(val route: SendRoute) : SendEffect() data object NavigateToAddress : SendEffect() @@ -3138,7 +3193,10 @@ internal fun resolvePastedPubkyRoute( input: String, ownPublicKey: String?, contacts: List, + isPaykitEnabled: Boolean = true, ): Routes? { + if (!isPaykitEnabled) return null + val normalizedKey = PubkyPublicKeyFormat.normalized(input) ?: return null if (PubkyPublicKeyFormat.matches(normalizedKey, ownPublicKey)) { diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index f2de88e1e..3a5ec40b7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -8,14 +8,22 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore +import to.bitkit.data.hasPaykitState +import to.bitkit.data.hasPublicPaykitPublicationState +import to.bitkit.data.paykitDisabled +import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.WidgetsRepo +import to.bitkit.utils.Logger import javax.inject.Inject @Suppress("TooManyFunctions") @@ -23,9 +31,25 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, private val pubkyRepo: PubkyRepo, + private val publicPaykitRepo: PublicPaykitRepo, + private val privatePaykitRepo: PrivatePaykitRepo, private val widgetsStore: WidgetsStore, private val widgetsRepo: WidgetsRepo, ) : ViewModel() { + private companion object { + const val TAG = "SettingsViewModel" + } + + init { + viewModelScope.launch { + val settings = settingsStore.data.first() + val isPaykitEnabled = PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) + if (!isPaykitEnabled && settings.hasPaykitState()) { + updatePaykitEnabled(false) + } + } + } + fun reset() = viewModelScope.launch { settingsStore.reset() } val hasSeenSpendingIntro = settingsStore.data.map { it.hasSeenSpendingIntro } @@ -158,6 +182,51 @@ class SettingsViewModel @Inject constructor( } } + val isPaykitEnabled = settingsStore.isPaykitEnabled.map { PaykitFeatureFlags.isUiEnabled(it) } + .asStateFlow(initialValue = false) + + val isPaykitStateLoaded = settingsStore.isPaykitEnabled.map { true } + .asStateFlow(initialValue = false) + + fun setIsPaykitEnabled(value: Boolean) { + viewModelScope.launch { + updatePaykitEnabled(value) + } + } + + private suspend fun updatePaykitEnabled(value: Boolean) { + val shouldEnable = value && PaykitFeatureFlags.isUiAvailable + val hadPublicPaykitState = settingsStore.data.first().hasPublicPaykitPublicationState() + settingsStore.setIsPaykitEnabled(shouldEnable) + + if (!shouldEnable) { + settingsStore.update { + it.paykitDisabled(markPublicCleanupPending = it.hasPublicPaykitPublicationState()) + } + removePaykitEndpoints(hadPublicPaykitState) + } + } + + private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean) { + val contacts = pubkyRepo.contacts.value.map { it.publicKey } + + if (hadPublicPaykitState) { + publicPaykitRepo.syncPublishedEndpoints(publish = false) + .onSuccess { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } + .onFailure { + settingsStore.update { it.copy(publicPaykitCleanupPending = true) } + Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG) + } + } + + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure { + Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) + } + } + val isPinEnabled = settingsStore.data.map { it.isPinEnabled } .asStateFlow(SharingStarted.Eagerly, false) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt new file mode 100644 index 000000000..2f6d672cc --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -0,0 +1,157 @@ +package to.bitkit.repositories + +import android.content.Context +import com.synonym.vssclient.VssItem +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.AppDb +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.data.backup.VssBackupClient +import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.dao.TransferDao +import to.bitkit.data.entities.TransferEntity +import to.bitkit.di.json +import to.bitkit.models.BackupCategory +import to.bitkit.models.PrivatePaykitContactLinkBackupV1 +import to.bitkit.models.WalletBackupV1 +import to.bitkit.services.LightningService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import javax.inject.Provider +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +class BackupRepoTest : BaseUnitTest() { + private val context = mock() + private val cacheStore = mock() + private val vssBackupClient = mock() + private val vssBackupClientLdk = mock() + private val settingsStore = mock() + private val widgetsStore = mock() + private val blocktankRepo = mock() + private val activityRepo = mock() + private val pubkyRepo = mock() + private val privatePaykitRepo = mock() + private val privatePaykitAddressReservationRepo = mock() + private val preActivityMetadataRepo = mock() + private val lightningService = mock() + private val clock = mock() + private val db = mock() + private val transferDao = mock() + private val settingsData = MutableStateFlow(SettingsData()) + + private lateinit var sut: BackupRepo + + @Before + fun setUp() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(1_000)) + whenever(db.transferDao()).thenReturn(transferDao) + whenever { transferDao.upsert(any>()) }.thenReturn(Unit) + whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) + whenever { cacheStore.update(any()) }.thenReturn(Unit) + whenever(settingsStore.data).thenReturn(settingsData) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever { vssBackupClient.getObject(any()) }.thenReturn(Result.success(null)) + whenever { vssBackupClient.putObject(any(), any()) } + .thenReturn(Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1))) + whenever { privatePaykitRepo.restoreBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) + whenever { + privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() + }.thenReturn(Result.success(Unit)) + + sut = createSut() + } + + @Test + fun `full restore should fail when private Paykit reservations fail to restore`() = test { + stubWalletBackup() + whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) } + .thenReturn(Result.failure(BackupRepoTestError("restore failed"))) + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isFailure) + verify(privatePaykitRepo, never()).restoreBackup(any()) + verify(settingsStore, never()).update(any()) + } + + @Test + fun `full restore should fail when private Paykit contact links fail to restore`() = test { + stubWalletBackup() + whenever { privatePaykitRepo.restoreBackup(anyOrNull()) } + .thenReturn(Result.failure(BackupRepoTestError("restore failed"))) + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isFailure) + verify(settingsStore, never()).update(any()) + } + + @Test + fun `full restore should fail when private Paykit reserved indexes fail to reconcile`() = test { + stubWalletBackup() + whenever { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() } + .thenReturn(Result.failure(BackupRepoTestError("reconcile failed"))) + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isFailure) + verify(settingsStore, never()).update(any()) + } + + private fun stubWalletBackup( + privatePaykitContactLinks: Map? = null, + ) { + val walletBackup = WalletBackupV1( + createdAt = 123, + transfers = emptyList(), + privatePaykitHighestReservedReceiveIndexByAddressType = mapOf("nativeSegwit" to 5), + privatePaykitContactLinks = privatePaykitContactLinks, + ) + whenever { vssBackupClient.getObject(BackupCategory.WALLET.name) } + .thenReturn( + Result.success( + VssItem( + key = BackupCategory.WALLET.name, + value = json.encodeToString(walletBackup).toByteArray(), + version = 1, + ) + ) + ) + } + + private fun createSut() = BackupRepo( + context = context, + ioDispatcher = testDispatcher, + cacheStore = cacheStore, + vssBackupClient = vssBackupClient, + vssBackupClientLdk = vssBackupClientLdk, + settingsStore = settingsStore, + widgetsStore = widgetsStore, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + pubkyRepo = pubkyRepo, + privatePaykitRepo = Provider { privatePaykitRepo }, + privatePaykitAddressReservationRepo = Provider { privatePaykitAddressReservationRepo }, + preActivityMetadataRepo = preActivityMetadataRepo, + lightningService = lightningService, + clock = clock, + db = db, + ) + + private class BackupRepoTestError(message: String) : AppError(message) +} diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 72a880410..63b5a9f66 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -497,6 +497,13 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `signOut should continue when endpoint cleanup fails`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = 123L, + ) whenever(pubkyService.getPaymentList(VALID_SELF_KEY)).thenAnswer { throw TestAppError("Cleanup failed") } val result = sut.signOut() @@ -504,6 +511,12 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertNull(sut.publicKey.value) assertFalse(sut.isAuthenticated.value) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertEquals("", settingsFlow.value.publicPaykitBolt11) + assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) + assertEquals(0, settingsFlow.value.publicPaykitBolt11ExpiresAtMillis) verifyBlocking(pubkyService) { signOut() } verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index f71e9ba47..ff072126c 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -114,6 +114,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { publicPaykitBolt11 = "lnbc1old", publicPaykitBolt11PaymentHash = "010203", publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + publicPaykitCleanupPending = true, ), ) @@ -131,6 +132,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("", settingsFlow.value.publicPaykitBolt11) + assertEquals(false, settingsFlow.value.publicPaykitCleanupPending) verify(pubkyRepo).removePaymentEndpoint(MethodId.Bolt11.rawValue) verify(pubkyRepo).removePaymentEndpoint(MethodId.P2tr.rawValue) verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Lnurl.rawValue) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index a652ba37f..27144b716 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -2,6 +2,8 @@ package to.bitkit.repositories import app.cash.turbine.test import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.GetAddressResponse +import com.synonym.bitkitcore.GetAddressesResponse import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -11,6 +13,7 @@ import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn @@ -30,6 +33,7 @@ import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase +import to.bitkit.utils.ServiceError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -683,4 +687,59 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.getOrNull()?.firstOrNull()?.path?.startsWith("m/86'") == true) verify(lightningRepo).addressInfosForType(AddressType.P2TR, isChange = false, startIndex = 0, count = 20) } + + @Test + fun `getAddresses should derive addresses from mnemonic when ldk address infos are unavailable`() = test { + whenever { lightningRepo.addressInfosForType(any(), any(), any(), any()) } + .thenReturn(Result.failure(ServiceError.NodeNotStarted())) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) + whenever( + onchainService.deriveBitcoinAddresses( + mnemonicPhrase = "test mnemonic", + derivationPathStr = "m/84'/1'/0'", + network = Network.REGTEST, + bip39Passphrase = null, + isChange = false, + startIndex = 3u, + count = 2u, + ), + ).thenReturn( + GetAddressesResponse( + listOf( + GetAddressResponse( + address = "bc1qfallback0000000000000000000000000000000", + path = "m/84'/0'/0'/0/3", + publicKey = "public-key", + ), + GetAddressResponse( + address = "bc1qfallback1111111111111111111111111111111", + path = "m/84'/0'/0'/0/4", + publicKey = "public-key", + ), + ), + ), + ) + + val result = sut.getAddresses(startIndex = 3, count = 2) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.stackTraceToString().orEmpty()) + val addresses = result.getOrThrow() + assertEquals(2, addresses.size) + assertEquals("bc1qfallback0000000000000000000000000000000", addresses[0].address) + assertEquals(3, addresses[0].index) + assertEquals("m/84'/1'/0'/0/3", addresses[0].path) + assertEquals("bc1qfallback1111111111111111111111111111111", addresses[1].address) + assertEquals(4, addresses[1].index) + assertEquals("m/84'/1'/0'/0/4", addresses[1].path) + verify(onchainService).deriveBitcoinAddresses( + mnemonicPhrase = "test mnemonic", + derivationPathStr = "m/84'/1'/0'", + network = Network.REGTEST, + bip39Passphrase = null, + isChange = false, + startIndex = 3u, + count = 2u, + ) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 94ef160bb..e0f099398 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,6 +1,8 @@ package to.bitkit.viewmodels import android.content.Context +import android.content.Intent +import androidx.core.net.toUri import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType @@ -14,6 +16,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -22,6 +25,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData @@ -70,6 +75,8 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) @Suppress("LargeClass") class AppViewModelSendFlowTest : BaseUnitTest() { @@ -103,6 +110,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val balanceState = MutableStateFlow(BalanceState()) private val settingsData = MutableStateFlow(SettingsData()) + private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) private val nodeEvents = MutableSharedFlow() private val pubkyPublicKey = MutableStateFlow(null) @@ -128,7 +136,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) whenever(walletRepo.walletExists()).thenReturn(true) - whenever(settingsStore.data).thenReturn(settingsData) + stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) @@ -176,6 +184,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.canSend(any())).thenReturn(true) } + private fun stubSettingsStore() { + whenever(settingsStore.data).thenReturn(settingsData) + whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsData.value = transform(settingsData.value) + Unit + } + } + private fun createViewModel() = AppViewModel( connectivityRepo = connectivityRepo, healthRepo = healthRepo, @@ -265,6 +283,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `manual address continue routes pubky to add contact`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.mainScreenEffect.test { sut.setSendEvent(SendEvent.AddressContinue(testPublicKey)) @@ -274,6 +295,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `manual address input accepts pubky without decode error`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setSendEvent(SendEvent.AddressChange(testPublicKey)) advanceUntilIdle() @@ -282,8 +306,75 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(coreService, never()).decode(any()) } + @Test + fun `manual address input rejects pubky when Paykit UI is disabled`() = test { + sut.setSendEvent(SendEvent.AddressChange(testPublicKey)) + advanceUntilIdle() + + assertEquals(testPublicKey, sut.sendUiState.value.addressInput) + assertFalse(sut.sendUiState.value.isAddressInputValid) + verify(coreService, never()).decode(any()) + } + + @Test + fun `contact button routes to coming soon when Paykit UI is disabled`() = test { + sut.sendEffect.test { + sut.setSendEvent(SendEvent.Contacts) + + assertEquals(SendEffect.NavigateToComingSoon, awaitItem()) + } + } + + @Test + fun `contact button routes to contact select when Paykit UI is enabled`() = test { + enablePaykitUi() + advanceUntilIdle() + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.Contacts) + + assertEquals(SendEffect.NavigateToContacts, awaitItem()) + } + } + + @Test + fun `pubky auth deeplink is ignored when Paykit UI is disabled`() = test { + val intent = Intent(Intent.ACTION_VIEW, "pubkyauth://auth?caps=/pub/paykit/v0/:rw".toUri()) + + sut.handleDeeplinkIntent(intent) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `pubky ring callback deeplink is ignored when Paykit UI is disabled`() = test { + val intent = Intent(Intent.ACTION_VIEW, "bitkit://pubky-auth/success".toUri()) + + sut.handleDeeplinkIntent(intent) + advanceUntilIdle() + + verify(pubkyRepo, never()).handleAuthCallback(any()) + } + + @Test + fun `pubky auth deeplink shows approval sheet when Paykit UI is enabled`() = test { + enablePaykitUi() + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } + @Test fun `pubky routing dismisses send sheet before navigation`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.showSheet(Sheet.Send()) advanceUntilIdle() @@ -786,6 +877,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `private Paykit waits for contacts load before pruning`() = test { + enablePaykitUi() + advanceUntilIdle() clearInvocations(privatePaykitRepo) pubkyPublicKey.value = testPublicKey @@ -803,6 +896,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `private Paykit removes stale contact without duplicate load version cleanup`() = test { + enablePaykitUi() + advanceUntilIdle() + val contact = PubkyProfile( publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo", name = "Bob", @@ -826,6 +922,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) } + @Test + fun `private Paykit refresh retries public cleanup while UI is disabled`() = test { + settingsData.value = SettingsData(publicPaykitCleanupPending = true) + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) + + sut.refreshPrivatePaykitEndpoints() + advanceUntilIdle() + + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + assertFalse(settingsData.value.publicPaykitCleanupPending) + verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) + verify(privatePaykitRepo, never()).reconcileReservedReceiveIndexes() + } + + @Test + fun `private Paykit refresh clears stale public cleanup when public sharing is enabled`() = test { + isPaykitEnabled.value = true + settingsData.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitCleanupPending = true, + ) + + sut.refreshPrivatePaykitEndpoints() + advanceUntilIdle() + + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + assertFalse(settingsData.value.publicPaykitCleanupPending) + verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) + } + private suspend fun TestScope.confirmCurrentPayment() { sut.setSendEvent(SendEvent.SwipeToPay) advanceUntilIdle() @@ -856,9 +982,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) private suspend fun enablePublicPaykitSharing() { - settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) - walletState.value = WalletState(onchainAddress = "bc1qtest") whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) + walletState.value = WalletState(onchainAddress = "bc1qtest") + isPaykitEnabled.value = true + settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) + } + + private fun enablePaykitUi() { + isPaykitEnabled.value = true } @Suppress("UNCHECKED_CAST") diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt new file mode 100644 index 000000000..350e4d908 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -0,0 +1,172 @@ +package to.bitkit.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest : BaseUnitTest() { + private lateinit var sut: SettingsViewModel + + private val settingsStore = mock() + private val pubkyRepo = mock() + private val publicPaykitRepo = mock() + private val privatePaykitRepo = mock() + private val widgetsStore = mock() + private val widgetsRepo = mock() + + private val settingsData = MutableStateFlow(SettingsData()) + private val isPaykitEnabled = MutableStateFlow(false) + private val contacts = MutableStateFlow( + listOf( + PubkyProfile( + publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo", + name = "Alice", + bio = "", + imageUrl = null, + links = emptyList(), + status = null, + ) + ) + ) + + @Before + fun setUp() { + whenever(settingsStore.data).thenReturn(settingsData) + whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsData.value = transform(settingsData.value) + Unit + } + whenever { settingsStore.setIsPaykitEnabled(any()) }.thenAnswer { + isPaykitEnabled.value = it.getArgument(0) + Unit + } + whenever(pubkyRepo.isAuthenticated).thenReturn(MutableStateFlow(false)) + whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } + .thenReturn(Result.success(Unit)) + + sut = createViewModel() + } + + @Test + fun `disabling Paykit clears settings and removes published endpoints`() = test { + isPaykitEnabled.value = true + settingsData.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "hash", + publicPaykitBolt11ExpiresAtMillis = 123L, + ) + + sut.setIsPaykitEnabled(false) + advanceUntilIdle() + + val settings = settingsData.value + assertFalse(isPaykitEnabled.value) + assertFalse(settings.hasConfirmedPublicPaykitEndpoints) + assertFalse(settings.sharesPublicPaykitEndpoints) + assertFalse(settings.sharesPrivatePaykitEndpoints) + assertFalse(settings.publicPaykitCleanupPending) + assertEquals("", settings.publicPaykitBolt11) + assertEquals("", settings.publicPaykitBolt11PaymentHash) + assertEquals(0L, settings.publicPaykitBolt11ExpiresAtMillis) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) + } + + @Test + fun `disabling Paykit keeps public cleanup pending when public removal fails`() = test { + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } + .thenReturn(Result.failure(SettingsViewModelTestError("cleanup failed"))) + isPaykitEnabled.value = true + settingsData.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + ) + + sut.setIsPaykitEnabled(false) + advanceUntilIdle() + + assertTrue(settingsData.value.publicPaykitCleanupPending) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) + } + + @Test + fun `disabling Paykit with private-only state does not create public cleanup pending`() = test { + clearInvocations(publicPaykitRepo) + isPaykitEnabled.value = true + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + ) + + sut.setIsPaykitEnabled(false) + advanceUntilIdle() + + assertFalse(settingsData.value.publicPaykitCleanupPending) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) + } + + @Test + fun `init disables stale Paykit publication state when local flag is off`() = test { + settingsData.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "hash", + publicPaykitBolt11ExpiresAtMillis = 123L, + ) + + createViewModel() + advanceUntilIdle() + + val settings = settingsData.value + assertFalse(isPaykitEnabled.value) + assertFalse(settings.hasConfirmedPublicPaykitEndpoints) + assertFalse(settings.sharesPublicPaykitEndpoints) + assertFalse(settings.sharesPrivatePaykitEndpoints) + assertFalse(settings.publicPaykitCleanupPending) + assertEquals("", settings.publicPaykitBolt11) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) + } + + private fun createViewModel() = SettingsViewModel( + settingsStore = settingsStore, + pubkyRepo = pubkyRepo, + publicPaykitRepo = publicPaykitRepo, + privatePaykitRepo = privatePaykitRepo, + widgetsStore = widgetsStore, + widgetsRepo = widgetsRepo, + ) + + private class SettingsViewModelTestError(message: String) : AppError(message) +} diff --git a/changelog.d/next/954.changed.md b/changelog.d/next/954.changed.md new file mode 100644 index 000000000..2ac5fd45c --- /dev/null +++ b/changelog.d/next/954.changed.md @@ -0,0 +1 @@ +Hide experimental Paykit profile, contacts, and contact payment controls behind a developer setting.