diff --git a/CHANGELOG.md b/CHANGELOG.md index 299beaf0f..1becb2589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Improve Pubky profile restore, contact editing, and contact routing flows #905 + ## [2.2.0] - 2026-04-07 ### Fixed diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index ab0a8980f..3b6a0b4ec 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.PreActivityMetadata +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData import to.bitkit.data.SettingsData @@ -26,8 +27,24 @@ data class MetadataBackupV1( val createdAt: Long, val tagMetadata: List, val cache: AppCacheData, + val pubkySession: PubkySessionBackupV1? = null, ) +@Serializable +data class PubkySessionBackupV1( + val kind: PubkySessionBackupKind, + val sessionSecret: String? = null, +) + +@Serializable +enum class PubkySessionBackupKind { + @SerialName("localSeed") + LocalSeed, + + @SerialName("externalSession") + ExternalSession, +} + @Serializable data class BlocktankBackupV1( val version: Int = 1, diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 6878e55ba..b99a47847 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -84,6 +84,7 @@ class BackupRepo @Inject constructor( private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, + private val pubkyRepo: PubkyRepo, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, private val clock: Clock, @@ -268,6 +269,16 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(preActivityMetadataJob) + val pubkyStateJob = scope.launch { + pubkyRepo.backupStateVersion + .drop(1) + .collect { + if (shouldSkipBackup()) return@collect + markBackupRequired(BackupCategory.METADATA) + } + } + dataListenerJobs.add(pubkyStateJob) + // BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info) val blocktankJob = scope.launch { blocktankRepo.blocktankState @@ -461,18 +472,7 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - BackupCategory.METADATA -> { - val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) - val cacheData = cacheStore.data.first() - - val payload = MetadataBackupV1( - createdAt = currentTimeMillis(), - tagMetadata = preActivityMetadata, - cache = cacheData, - ) - - json.encodeToString(payload).toByteArray() - } + BackupCategory.METADATA -> getMetadataBackupDataBytes() BackupCategory.BLOCKTANK -> { val blocktankState = blocktankRepo.blocktankState.first() @@ -505,6 +505,21 @@ class BackupRepo @Inject constructor( BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } + private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { + val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) + val cacheData = cacheStore.data.first() + val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) + + val payload = MetadataBackupV1( + createdAt = currentTimeMillis(), + tagMetadata = preActivityMetadata, + cache = cacheData, + pubkySession = pubkySession, + ) + + json.encodeToString(payload).toByteArray() + } + suspend fun performFullRestoreFromLatestBackup( onCacheRestored: suspend () -> Unit = {}, ): Result = withContext(ioDispatcher) { @@ -520,6 +535,10 @@ class BackupRepo @Inject constructor( Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata).getOrNull() + pubkyRepo.restoreSessionBackupState(parsed.pubkySession) + .onFailure { + Logger.warn("Failed to restore pubky session backup state", it, context = TAG) + } Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG) 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 1fd3778ab..6cf2104c2 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.data.PubkyStore import to.bitkit.data.keychain.Keychain @@ -32,6 +33,8 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.PubkySessionBackupKind +import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -66,8 +69,11 @@ class PubkyRepo @Inject constructor( } private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val serviceInitializeMutex = Mutex() + private val initializeMutex = Mutex() private val loadProfileMutex = Mutex() private val loadContactsMutex = Mutex() + private var isServiceInitialized = false private val _authState = MutableStateFlow(PubkyAuthState.Idle) @@ -95,6 +101,9 @@ class PubkyRepo @Inject constructor( private val _pendingImportContacts = MutableStateFlow>(emptyList()) val pendingImportContacts: StateFlow> = _pendingImportContacts.asStateFlow() + private val _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + val isAuthenticated: StateFlow = _authState.map { it == PubkyAuthState.Authenticated } .stateIn(scope, SharingStarted.Eagerly, false) @@ -118,67 +127,104 @@ class PubkyRepo @Inject constructor( // region Initialization - private suspend fun initialize() { - val result = runCatching { - withContext(ioDispatcher) { - pubkyService.initialize() + suspend fun initialize() = withContext(ioDispatcher) { + runCatching { + ensureServiceInitialized() + }.onFailure { + Logger.error("Failed to initialize paykit", it, context = TAG) + }.getOrNull() ?: return@withContext - val savedSecret = runCatching { + initializeMutex.withLock { + _sessionRestorationFailed.update { false } + val result = runCatching { + val savedSessionSecret = runCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }.getOrNull() + val storedSecretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.getOrNull() - if (savedSecret.isNullOrEmpty()) { - return@withContext InitResult.NoSession - } + resolveSessionInitialization( + savedSessionSecret = savedSessionSecret, + storedSecretKeyHex = storedSecretKeyHex, + ) + }.onFailure { + Logger.error("Failed to initialize paykit", it, context = TAG) + }.getOrNull() ?: return@withLock - runCatching { - val pk = pubkyService.importSession(savedSecret) - InitResult.Restored(pk) - }.getOrElse { importError -> - Logger.warn("Failed to restore paykit session, attempting re-sign-in", importError, context = TAG) - tryReSignIn() + when (result) { + is InitResult.NoSession -> { + clearAuthenticatedState() + Logger.debug("Found no saved paykit session", context = TAG) + } + is InitResult.Restored -> { + _publicKey.update { result.publicKey } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Restored paykit session for '${result.publicKey}'", context = TAG) + loadProfile() + loadContacts() + } + is InitResult.RestorationFailed -> { + clearAuthenticatedState() + _sessionRestorationFailed.update { true } } } - }.onFailure { - Logger.error("Failed to initialize paykit", it, context = TAG) - }.getOrNull() ?: return + } + } - when (result) { - is InitResult.NoSession -> Logger.debug("Found no saved paykit session", context = TAG) - is InitResult.Restored -> { - _publicKey.update { result.publicKey } - _authState.update { PubkyAuthState.Authenticated } - Logger.info("Paykit session restored for '${result.publicKey}'", context = TAG) - loadProfile() - loadContacts() - } - is InitResult.RestorationFailed -> { - _sessionRestorationFailed.update { true } + private suspend fun ensureServiceInitialized() = withContext(ioDispatcher) { + serviceInitializeMutex.withLock { + if (!isServiceInitialized) { + pubkyService.initialize() + isServiceInitialized = true } } } - private suspend fun tryReSignIn(): InitResult { - val secretKeyHex = runCatching { - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - }.getOrNull() - - if (secretKeyHex.isNullOrEmpty()) { - Logger.warn("Skipped re-sign-in recovery, no secret key available", context = TAG) - return InitResult.RestorationFailed + private suspend fun resolveSessionInitialization( + savedSessionSecret: String?, + storedSecretKeyHex: String?, + ): InitResult = withContext(ioDispatcher) { + if (!savedSessionSecret.isNullOrEmpty()) { + runCatching { + val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() + InitResult.Restored(publicKey) + }.getOrElse { + Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) + resolveSignedInSession(savedSessionSecret, storedSecretKeyHex) + } + } else { + resolveSignedInSession(savedSessionSecret, storedSecretKeyHex) } + } - return runCatching { - val newSession = pubkyService.signIn(secretKeyHex) - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - keychain.saveString(Keychain.Key.PAYKIT_SESSION.name, newSession) - val pk = pubkyService.importSession(newSession) - Logger.info("Re-signed in and restored session for '$pk'", context = TAG) - InitResult.Restored(pk) - }.getOrElse { - Logger.error("Re-sign-in recovery failed", it, context = TAG) - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - InitResult.RestorationFailed + private suspend fun resolveSignedInSession( + savedSessionSecret: String?, + storedSecretKeyHex: String?, + ): InitResult = withContext(ioDispatcher) { + if (storedSecretKeyHex.isNullOrEmpty()) { + if (!savedSessionSecret.isNullOrEmpty()) { + Logger.warn("Skipped re-sign-in recovery, no secret key available", context = TAG) + runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + notifyBackupStateChanged() + InitResult.RestorationFailed + } else { + InitResult.NoSession + } + } else { + runCatching { + val newSession = pubkyService.signIn(storedSecretKeyHex) + keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) + notifyBackupStateChanged() + val publicKey = pubkyService.importSession(newSession).ensurePubkyPrefix() + Logger.info("Re-signed in and restored session for '$publicKey'", context = TAG) + InitResult.Restored(publicKey) + }.getOrElse { + Logger.error("Failed re-sign-in recovery", it, context = TAG) + runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + notifyBackupStateChanged() + InitResult.RestorationFailed + } } } @@ -203,11 +249,11 @@ class PubkyRepo @Inject constructor( return runCatching { withContext(ioDispatcher) { val sessionSecret = pubkyService.completeAuth() - val pk = pubkyService.importSession(sessionSecret) + val pk = pubkyService.importSession(sessionSecret).ensurePubkyPrefix() - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - keychain.saveString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) + keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) + notifyBackupStateChanged() pk } @@ -216,7 +262,7 @@ class PubkyRepo @Inject constructor( }.onSuccess { pk -> _publicKey.update { pk } _authState.update { PubkyAuthState.Authenticated } - Logger.info("Pubky auth completed for '$pk'", context = TAG) + Logger.info("Completed pubky auth for '$pk'", context = TAG) loadProfile() }.map { } } @@ -224,7 +270,7 @@ class PubkyRepo @Inject constructor( suspend fun cancelAuthentication() { runCatching { withContext(ioDispatcher) { pubkyService.cancelAuth() } - }.onFailure { Logger.warn("Cancel auth failed", it, context = TAG) } + }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) } _authState.update { PubkyAuthState.Idle } } @@ -289,12 +335,7 @@ class PubkyRepo @Inject constructor( suspend fun deriveKeys(): Result> = runCatching { withContext(ioDispatcher) { - val mnemonic = requireNotNull(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) { - "BIP39 mnemonic not found in keychain" - } - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - val seed = pubkyService.mnemonicToSeed(mnemonic, passphrase) - val secretKeyHex = pubkyService.deriveSecretKey(seed) + val secretKeyHex = deriveLocalSecretKeyFromWalletSeed() val rawKey = pubkyService.publicKeyFromSecret(secretKeyHex) val publicKeyZ32 = rawKey.ensurePubkyPrefix() Pair(publicKeyZ32, secretKeyHex) @@ -319,12 +360,13 @@ class PubkyRepo @Inject constructor( val session = runCatching { pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) }.getOrElse { - Logger.warn("signUp failed (likely already registered), trying signIn", it, context = TAG) + Logger.warn("Retrying sign in after sign up failed", it, context = TAG) pubkyService.signIn(secretKeyHex) } keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, session) + notifyBackupStateChanged() pubkyService.importSession(session) val imageUrl = avatarBytes?.let { uploadAvatar(it, secretKeyHex).getOrNull() } @@ -332,7 +374,7 @@ class PubkyRepo @Inject constructor( _publicKey.update { publicKeyZ32 } _authState.update { PubkyAuthState.Authenticated } - Logger.info("Identity created for '$publicKeyZ32'", context = TAG) + Logger.info("Created identity for '$publicKeyZ32'", context = TAG) loadProfile() loadContacts() } @@ -395,13 +437,30 @@ class PubkyRepo @Inject constructor( } } + suspend fun deleteProfileWithSessionRetry(): Result = withContext(ioDispatcher) { + val initialResult = deleteProfile() + if (initialResult.isSuccess) return@withContext initialResult + + val refreshedSession = refreshSessionIfPossible().getOrDefault(false) + if (!refreshedSession) return@withContext initialResult + + deleteProfile() + } + suspend fun deleteProfile(): Result = runCatching { withContext(ioDispatcher) { val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } deleteAllContacts(session) - pubkyService.sessionDelete(session, Env.profilePath) + runCatching { + pubkyService.sessionDelete(session, Env.profilePath) + }.getOrElse { + if (!it.isMissingPubkyData()) { + throw it + } + Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG) + } } signOut().getOrThrow() } @@ -664,7 +723,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun clearPendingImport() { + suspend fun clearPendingImport() = withContext(ioDispatcher) { _pendingImportProfile.update { null } _pendingImportContacts.update { emptyList() } } @@ -690,6 +749,80 @@ class PubkyRepo @Inject constructor( // endregion + // region Backup state + + suspend fun snapshotSessionBackupState(): Result = runCatching { + withContext(ioDispatcher) { + val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + if (!secretKeyHex.isNullOrEmpty()) { + return@withContext PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed) + } + + val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + if (!sessionSecret.isNullOrEmpty()) { + return@withContext PubkySessionBackupV1( + kind = PubkySessionBackupKind.ExternalSession, + sessionSecret = sessionSecret, + ) + } + + null + } + } + + suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runCatching { + withContext(ioDispatcher) { + if (backup == null) { + notifyBackupStateChanged() + return@withContext + } + + ensureServiceInitialized() + + initializeMutex.withLock { + pubkyService.forceSignOut() + clearAuthenticatedState() + runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + + when (backup.kind) { + PubkySessionBackupKind.LocalSeed -> { + val secretKeyHex = deriveLocalSecretKeyFromWalletSeed() + keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) + } + + PubkySessionBackupKind.ExternalSession -> { + val sessionSecret = requireNotNull(backup.sessionSecret?.takeIf { it.isNotBlank() }) { + "Missing session secret in backup" + } + keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) + } + } + + notifyBackupStateChanged() + } + } + } + + suspend fun refreshSessionIfPossible(): Result = runCatching { + withContext(ioDispatcher) { + val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + ?: return@withContext false + + val sessionSecret = pubkyService.signIn(storedSecretKeyHex) + val publicKey = pubkyService.importSession(sessionSecret).ensurePubkyPrefix() + + keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + + true + } + } + + // endregion + // region Sign out suspend fun signOut(): Result { @@ -752,11 +885,22 @@ class PubkyRepo @Inject constructor( null } - private suspend fun clearLocalState() { - runCatching { withContext(ioDispatcher) { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } } - runCatching { withContext(ioDispatcher) { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } } + private suspend fun deriveLocalSecretKeyFromWalletSeed(): String = withContext(ioDispatcher) { + val mnemonic = requireNotNull(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) { + "BIP39 mnemonic not found in keychain" + } + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val seed = pubkyService.mnemonicToSeed(mnemonic, passphrase) + pubkyService.deriveSecretKey(seed) + } + + private fun notifyBackupStateChanged() { + _backupStateVersion.update { it + 1 } + } + + private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { evictPubkyImages() - runCatching { withContext(ioDispatcher) { pubkyStore.reset() } } + runCatching { pubkyStore.reset() } _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } @@ -765,6 +909,13 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Idle } } + private suspend fun clearLocalState() = withContext(ioDispatcher) { + runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + notifyBackupStateChanged() + clearAuthenticatedState() + } + private fun requireAddableContactPublicKey(publicKey: String): String { val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: throw PubkyContactError.InvalidFormat diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 3f333deee..bae2bab20 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -288,7 +288,7 @@ fun ContentView( navController.navigateToHome() delay(100) // Small delay to ensure navigation completes } - appViewModel.onScanResult(it.data) + appViewModel.onScanResult(it.data, routePubkyKeys = true) } else -> Unit @@ -385,6 +385,7 @@ fun ContentView( val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() val hasSeenContactsIntro by settingsViewModel.hasSeenContactsIntro.collectAsStateWithLifecycle() val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() + val hasPubkyContacts by settingsViewModel.hasPubkyContacts.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() Box( @@ -526,6 +527,7 @@ fun ContentView( }, hasSeenProfileIntro = hasSeenProfileIntro, hasSeenContactsIntro = hasSeenContactsIntro, + hasContacts = hasPubkyContacts, isProfileAuthenticated = isProfileAuthenticated, modifier = Modifier.align(Alignment.TopEnd) ) @@ -933,7 +935,8 @@ private fun NavGraphBuilder.contacts( settingsViewModel: SettingsViewModel, appViewModel: AppViewModel, ) { - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() val viewModel: ContactsViewModel = hiltViewModel() ContactsScreen( viewModel = viewModel, @@ -946,6 +949,7 @@ private fun NavGraphBuilder.contacts( navController.navigateTo(Routes.AddContact(scannedData)) } }, + openAddContactSheet = route.showAddContactSheet, ) } composableWithDefaultTransitions { @@ -954,12 +958,14 @@ private fun NavGraphBuilder.contacts( ContactsIntroScreen( onContinue = { settingsViewModel.setHasSeenContactsIntro(true) - val destination = when { - isAuthenticated -> Routes.Contacts - hasSeenProfileIntro -> Routes.PubkyChoice - else -> Routes.ProfileIntro + 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) } } - navController.navigateTo(destination) { popUpTo(Routes.Home) } }, onBackClick = { navController.popBackStack() }, ) @@ -986,7 +992,7 @@ private fun NavGraphBuilder.contacts( viewModel = viewModel, onBackClick = { navController.popBackStack() }, onContactDeleted = { - navController.navigateTo(Routes.Contacts) { popUpTo(Routes.Home) } + navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) } }, ) } @@ -1060,12 +1066,18 @@ private fun NavGraphBuilder.profile( ) } composableWithDefaultTransitions { + val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() val viewModel: EditProfileViewModel = hiltViewModel() EditProfileScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onProfileDeleted = { - navController.navigateTo(Routes.PubkyChoice) { popUpTo(Routes.Home) } + onExitProfile = { + val nextRoute = if (hasSeenProfileIntro) { + Routes.PubkyChoice + } else { + Routes.ProfileIntro + } + navController.navigateTo(nextRoute) { popUpTo(Routes.Home) } }, ) } @@ -1888,7 +1900,7 @@ sealed interface Routes { data object LanguageSettings : Routes @Serializable - data object Contacts : Routes + data class Contacts(val showAddContactSheet: Boolean = false) : Routes @Serializable data object ContactsIntro : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt index eba6a7b12..93956881a 100644 --- a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt +++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -34,7 +35,7 @@ fun CenteredProfileHeader( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier, + modifier = modifier ) { Text13Up( text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH), @@ -52,13 +53,13 @@ fun CenteredProfileHeader( modifier = Modifier .size(100.dp) .clip(CircleShape) - .background(Colors.Gray5), + .background(Colors.Gray5) ) { Icon( painter = painterResource(R.drawable.ic_user_square), contentDescription = null, tint = Colors.White32, - modifier = Modifier.size(50.dp), + modifier = Modifier.size(50.dp) ) } } @@ -67,7 +68,10 @@ fun CenteredProfileHeader( Display( text = name, - modifier = if (nameTestTag != null) Modifier.testTag(nameTestTag) else Modifier, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = if (nameTestTag != null) Modifier.testTag(nameTestTag) else Modifier ) if (bio.isNotEmpty()) { @@ -76,7 +80,7 @@ fun CenteredProfileHeader( text = bio, color = Colors.White64, textAlign = TextAlign.Center, - modifier = if (notesTestTag != null) Modifier.testTag(notesTestTag) else Modifier, + modifier = if (notesTestTag != null) Modifier.testTag(notesTestTag) else Modifier ) } } 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 1b386fad7..d3849e414 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -73,6 +73,7 @@ fun DrawerMenu( modifier: Modifier = Modifier, hasSeenProfileIntro: Boolean = false, hasSeenContactsIntro: Boolean = false, + hasContacts: Boolean = false, isProfileAuthenticated: Boolean = false, ) { val scope = rememberCoroutineScope() @@ -127,14 +128,14 @@ fun DrawerMenu( }, onClickContacts = { when { - !hasSeenContactsIntro -> { + !hasSeenContactsIntro && !hasContacts -> { onBeforeNavigate(Routes.ContactsIntro) rootNavController.navigateIfNotCurrent(Routes.ContactsIntro) } isProfileAuthenticated -> { - onBeforeNavigate(Routes.Contacts) - rootNavController.navigateIfNotCurrent(Routes.Contacts) + onBeforeNavigate(Routes.Contacts()) + rootNavController.navigateIfNotCurrent(Routes.Contacts()) } hasSeenProfileIntro -> { diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index fee2eaa4d..84f76d96f 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.components +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -22,6 +23,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -31,6 +34,7 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface @@ -59,13 +63,17 @@ fun ProfileEditForm( modifier: Modifier = Modifier, avatarContent: @Composable () -> Unit = {}, publicKeyLabel: String? = null, + bioPlaceholder: String? = null, footerNote: String? = null, showFooterNote: Boolean = true, onDelete: (() -> Unit)? = null, deleteLabel: String = "", ) { val resolvedPublicKeyLabel = publicKeyLabel ?: stringResource(R.string.profile__your_pubky) + val resolvedBioPlaceholder = bioPlaceholder ?: stringResource(R.string.profile__edit_bio_placeholder) val resolvedFooterNote = footerNote ?: stringResource(R.string.profile__edit_public_note) + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -88,7 +96,7 @@ fun ProfileEditForm( colors = AppTextFieldDefaults.transparent, modifier = Modifier .fillMaxWidth() - .testTag("ProfileEditName"), + .testTag("ProfileEditName") ) HorizontalDivider() VerticalSpacer(12.dp) @@ -115,12 +123,12 @@ fun ProfileEditForm( TextInput( value = bio, onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) }, - placeholder = stringResource(R.string.profile__edit_bio_placeholder), + placeholder = resolvedBioPlaceholder, minLines = 2, maxLines = 4, modifier = Modifier .fillMaxWidth() - .testTag("ProfileEditBio"), + .testTag("ProfileEditBio") ) VerticalSpacer(16.dp) @@ -139,25 +147,48 @@ fun ProfileEditForm( placeholder = stringResource(R.string.profile__add_link_url_placeholder), singleLine = true, trailingIcon = { - IconButton(onClick = { onRemoveLink(index) }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { Icon( - painter = painterResource(R.drawable.ic_trash), + painter = painterResource(R.drawable.ic_pencil_simple), contentDescription = null, tint = Colors.White64, modifier = Modifier.size(16.dp) ) + IconButton( + onClick = { onRemoveLink(index) }, + modifier = Modifier.testTag("ProfileEditLinkRemove_$index") + ) { + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp) + ) + } } }, modifier = Modifier .fillMaxWidth() - .testTag("ProfileEditLink_$index"), + .border( + width = 1.dp, + color = Colors.White10, + shape = AppShapes.small, + ) + .testTag("ProfileEditLink_$index") ) VerticalSpacer(8.dp) } Row(modifier = Modifier.fillMaxWidth()) { PrimaryButton( text = stringResource(R.string.profile__add_link), - onClick = onAddLink, + onClick = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onAddLink() + }, size = ButtonSize.Small, fullWidth = false, icon = { @@ -167,7 +198,7 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, - modifier = Modifier.testTag("ProfileEditAddLink"), + modifier = Modifier.testTag("ProfileEditAddLink") ) } @@ -195,7 +226,11 @@ fun ProfileEditForm( Row(modifier = Modifier.fillMaxWidth()) { PrimaryButton( text = stringResource(R.string.profile__add_tag), - onClick = onAddTag, + onClick = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onAddTag() + }, size = ButtonSize.Small, fullWidth = false, icon = { @@ -205,12 +240,14 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, - modifier = Modifier.testTag("ProfileEditAddTag"), + modifier = Modifier.testTag("ProfileEditAddTag") ) } VerticalSpacer(16.dp) if (showFooterNote) { + HorizontalDivider(color = Colors.White10) + VerticalSpacer(16.dp) BodyS( text = resolvedFooterNote, color = Colors.White64, @@ -218,7 +255,7 @@ fun ProfileEditForm( } if (onDelete != null) { - Column(modifier = Modifier.testTag("ProfileEditDelete")) { + Column { VerticalSpacer(16.dp) HorizontalDivider() VerticalSpacer(16.dp) @@ -242,6 +279,7 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, + modifier = Modifier.testTag("ProfileEditDelete") ) } } @@ -258,7 +296,7 @@ fun ProfileEditForm( onClick = onCancel, modifier = Modifier .weight(1f) - .testTag("ProfileEditCancel"), + .testTag("ProfileEditCancel") ) PrimaryButton( text = stringResource(R.string.common__save), @@ -266,7 +304,7 @@ fun ProfileEditForm( enabled = isSaveEnabled, modifier = Modifier .weight(1f) - .testTag("ProfileEditSave"), + .testTag("ProfileEditSave") ) } VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/components/Tag.kt b/app/src/main/java/to/bitkit/ui/components/Tag.kt index cf6784141..47c1cfa21 100644 --- a/app/src/main/java/to/bitkit/ui/components/Tag.kt +++ b/app/src/main/java/to/bitkit/ui/components/Tag.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter 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 import androidx.compose.ui.unit.dp import to.bitkit.R @@ -47,6 +48,8 @@ fun TagButton( BodySSB( text = text, color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag("Tag-$text") ) diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index ee98a2ce7..519baa5be 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -24,6 +24,8 @@ fun Display( fontWeight: FontWeight = FontWeight.Black, fontSize: TextUnit = 44.sp, color: Color = MaterialTheme.colorScheme.primary, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, textAlign: TextAlign? = null, ) { Text( @@ -33,8 +35,10 @@ fun Display( fontSize = fontSize, color = color, ), + maxLines = maxLines, + overflow = overflow, textAlign = textAlign, - modifier = modifier, + modifier = modifier ) } @@ -49,7 +53,7 @@ fun Display( style = AppTextStyles.Display.merge( color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -64,7 +68,7 @@ fun Headline( style = AppTextStyles.Headline.merge( color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -82,7 +86,7 @@ fun Headline20( letterSpacing = (-.5).sp, color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -99,7 +103,7 @@ fun Headline24( lineHeight = 24.sp, color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -120,7 +124,7 @@ fun Title( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -141,7 +145,7 @@ fun Subtitle( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -185,7 +189,7 @@ fun BodyM( maxLines = maxLines, minLines = minLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -225,7 +229,7 @@ fun BodyMSB( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -246,7 +250,7 @@ fun BodyMB( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -339,7 +343,7 @@ fun BodySB( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -356,7 +360,7 @@ fun Text13Up( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -377,7 +381,7 @@ fun Caption( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -434,7 +438,7 @@ fun Caption13Up( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -455,6 +459,6 @@ fun Footnote( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 18f8a8a75..5ef753e41 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization @@ -86,15 +87,15 @@ fun AddContactSheet( ) { val context = LocalContext.current var publicKeyInput by remember { mutableStateOf("") } - val trimmedInput = publicKeyInput.trim() - val normalizedInput = PubkyPublicKeyFormat.normalized(trimmedInput) - val validationMessage = when { - trimmedInput.isEmpty() -> null - PubkyPublicKeyFormat.matches(trimmedInput, currentPublicKey) -> - context.getString(R.string.contacts__add_error_self) - normalizedInput == null -> - context.getString(R.string.contacts__add_error_invalid_key) - else -> null + val validationResult = resolveAddContactValidation( + input = publicKeyInput, + ownPublicKey = currentPublicKey, + ) + val validationMessage = when (validationResult) { + AddContactValidationResult.Empty -> null + AddContactValidationResult.InvalidKey -> context.getString(R.string.contacts__add_error_invalid_key) + AddContactValidationResult.OwnKey -> context.getString(R.string.contacts__add_error_self) + is AddContactValidationResult.Valid -> null } BottomSheet( @@ -104,7 +105,7 @@ fun AddContactSheet( AddContactSheetContent( publicKeyInput = publicKeyInput, validationMessage = validationMessage, - isSubmitEnabled = normalizedInput != null && validationMessage == null, + isSubmitEnabled = validationResult is AddContactValidationResult.Valid, onPublicKeyChange = { publicKeyInput = PubkyPublicKeyFormat.bounded(it) }, onPaste = { context.getClipboardText()?.trim()?.let { @@ -112,7 +113,11 @@ fun AddContactSheet( } }, onScanQr = onScanQr, - onSubmit = { normalizedInput?.let(onSubmit) }, + onSubmit = { + val normalizedKey = (validationResult as? AddContactValidationResult.Valid)?.normalizedKey + ?: return@AddContactSheetContent + onSubmit(normalizedKey) + }, ) } } @@ -156,7 +161,10 @@ private fun AddContactSheetContent( } }, trailingIcon = { - IconButton(onClick = onPaste) { + IconButton( + onClick = onPaste, + modifier = Modifier.testTag("AddContactPaste") + ) { Icon( painter = painterResource(R.drawable.ic_clipboard_text), contentDescription = null, @@ -164,7 +172,9 @@ private fun AddContactSheetContent( ) } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag("AddContactPubkyField") ) VerticalSpacer(16.dp) @@ -175,13 +185,17 @@ private fun AddContactSheetContent( SecondaryButton( text = stringResource(R.string.contacts__add_scan_qr), onClick = onScanQr, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag("AddContactScanQR") ) PrimaryButton( text = stringResource(R.string.contacts__add_button), onClick = onSubmit, enabled = isSubmitEnabled, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag("AddContactAdd") ) } VerticalSpacer(16.dp) @@ -257,7 +271,7 @@ private fun LoadingContent(publicKey: String) { horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp), + .padding(horizontal = 32.dp) ) { VerticalSpacer(24.dp) @@ -273,7 +287,7 @@ private fun LoadingContent(publicKey: String) { modifier = Modifier .size(80.dp) .clip(CircleShape) - .background(Colors.Gray5), + .background(Colors.Gray5) ) { Display( text = publicKey.take(1).uppercase(), @@ -292,7 +306,7 @@ private fun LoadingContent(publicKey: String) { contentAlignment = Alignment.Center, modifier = Modifier .weight(1f) - .fillMaxWidth(), + .fillMaxWidth() ) { RotatingEllipses(modifier = Modifier.size(256.dp)) } @@ -377,7 +391,7 @@ private fun RotatingEllipses(modifier: Modifier = Modifier) { style = dashedStroke, ) } - }, + } ) { Image( painter = painterResource(R.drawable.card), @@ -397,13 +411,14 @@ private fun ErrorContent( verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp), + .padding(horizontal = 32.dp) ) { BodyM(text = error, color = Colors.White64, textAlign = TextAlign.Center) VerticalSpacer(16.dp) SecondaryButton( text = stringResource(R.string.common__retry), onClick = onRetry, + modifier = Modifier.testTag("AddContactRetry") ) } } @@ -419,7 +434,7 @@ private fun LoadedContent( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp), + .padding(horizontal = 32.dp) ) { VerticalSpacer(24.dp) @@ -445,13 +460,17 @@ private fun LoadedContent( SecondaryButton( text = stringResource(R.string.contacts__add_discard), onClick = onDiscard, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag("AddContactDiscard") ) PrimaryButton( text = stringResource(R.string.common__save), onClick = onSave, enabled = !isLoading, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag("AddContactSave") ) } VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index bdfcb00c1..2e0ef85e3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -86,6 +86,7 @@ class AddContactViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.SUCCESS, title = context.getString(R.string.contacts__add_contact_saved), + testTag = "ContactSavedToast", ) _effects.emit(AddContactEffect.ContactSaved) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 3c7f3fa88..05aee9370 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -125,8 +125,8 @@ private fun Content( if (uiState.showDeleteDialog && currentProfile != null) { AppAlertDialog( title = stringResource(R.string.contacts__delete_confirm_title, currentProfile.name), - text = stringResource(R.string.contacts__delete_confirm_text), - confirmText = stringResource(R.string.contacts__delete_contact), + text = stringResource(R.string.contacts__delete_confirm_text, currentProfile.name), + confirmText = stringResource(R.string.common__delete_yes), onConfirm = onConfirmDelete, onDismiss = onDismissDeleteDialog, ) @@ -174,27 +174,27 @@ private fun ContactBody( Row( horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { ActionButton( onClick = onClickCopy, iconRes = R.drawable.ic_copy, - modifier = Modifier.testTag("ContactCopy"), + modifier = Modifier.testTag("ContactCopy") ) ActionButton( onClick = onClickShare, iconRes = R.drawable.ic_share, - modifier = Modifier.testTag("ContactShare"), + modifier = Modifier.testTag("ContactShare") ) ActionButton( onClick = onClickEdit, iconRes = R.drawable.ic_edit, - modifier = Modifier.testTag("ContactEdit"), + modifier = Modifier.testTag("ContactEdit") ) ActionButton( onClick = onClickDelete, iconRes = R.drawable.ic_trash, - modifier = Modifier.testTag("ContactDelete"), + modifier = Modifier.testTag("ContactDelete") ) } @@ -210,13 +210,13 @@ private fun ContactBody( color = Colors.White64, modifier = Modifier .fillMaxWidth() - .testTag("ContactViewTagsHeader"), + .testTag("ContactViewTagsHeader") ) VerticalSpacer(8.dp) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { tags.forEachIndexed { index, tag -> TagButton( @@ -233,7 +233,7 @@ private fun ContactBody( onClick = onAddTag, icon = painterResource(R.drawable.ic_tag), displayIconClose = true, - modifier = Modifier.testTag("ContactAddTag"), + modifier = Modifier.testTag("ContactAddTag") ) } } @@ -263,7 +263,7 @@ private fun EmptyState(onClickRetry: () -> Unit) { SecondaryButton( text = stringResource(R.string.profile__retry_load), onClick = onClickRetry, - modifier = Modifier.testTag("ContactRetry"), + modifier = Modifier.testTag("ContactRetry") ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 30a47fba7..67ce31242 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -145,6 +145,11 @@ class ContactDetailViewModel @Inject constructor( _uiState.update { it.copy(showDeleteDialog = false) } pubkyRepo.removeContact(publicKey) .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.contacts__delete_success), + testTag = "ContactDeletedToast", + ) _effects.emit(ContactDetailEffect.DeleteSuccess) } .onFailure { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt index 9c112cf43..bcac9b85a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt @@ -3,11 +3,39 @@ package to.bitkit.ui.screens.contacts import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.ui.Routes internal fun hasPendingImport(profile: PubkyProfile?, contacts: List): Boolean = profile != null && contacts.isNotEmpty() +internal sealed interface AddContactValidationResult { + data object Empty : AddContactValidationResult + data object InvalidKey : AddContactValidationResult + data object OwnKey : AddContactValidationResult + data class Valid(val normalizedKey: String) : AddContactValidationResult +} + +internal fun resolveAddContactValidation( + input: String, + ownPublicKey: String?, +): AddContactValidationResult { + val trimmedInput = input.trim() + + if (trimmedInput.isEmpty()) { + return AddContactValidationResult.Empty + } + + if (PubkyPublicKeyFormat.matches(trimmedInput, ownPublicKey)) { + return AddContactValidationResult.OwnKey + } + + val normalizedKey = PubkyPublicKeyFormat.normalized(trimmedInput) + ?: return AddContactValidationResult.InvalidKey + + return AddContactValidationResult.Valid(normalizedKey = normalizedKey) +} + internal fun shouldDiscardPendingImport(currentDestination: NavDestination?, destination: Routes?): Boolean { if (!currentDestination.isContactImportRoute()) { return false diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt index 8c69f52bf..4b596613e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -170,10 +171,19 @@ private fun SelectableContactRow( Column( verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { - BodyS(text = contact.profile.truncatedPublicKey, color = Colors.White64) - BodyMSB(text = contact.profile.name) + BodyS( + text = contact.profile.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyMSB( + text = contact.profile.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } HorizontalSpacer(16.dp) @@ -183,7 +193,7 @@ private fun SelectableContactRow( painter = painterResource(R.drawable.ic_check), contentDescription = null, tint = Colors.PubkyGreen, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } } @@ -218,7 +228,7 @@ private fun FooterBar( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { BodyMSB( text = stringResource(R.string.contacts__import_selected_count, selectedCount), diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt index cd0578791..5f272d99c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt @@ -17,13 +17,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -35,6 +36,7 @@ import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton @@ -57,6 +59,7 @@ fun ContactsScreen( onClickContact: (String) -> Unit, onAddContact: (String) -> Unit = {}, onScanQr: () -> Unit = {}, + openAddContactSheet: Boolean = false, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -70,6 +73,7 @@ fun ContactsScreen( onSearchTextChange = { viewModel.onSearchTextChange(it) }, onAddContact = onAddContact, onScanQr = onScanQr, + openAddContactSheet = openAddContactSheet, ) } @@ -82,8 +86,9 @@ private fun Content( onSearchTextChange: (String) -> Unit, onAddContact: (String) -> Unit, onScanQr: () -> Unit, + openAddContactSheet: Boolean, ) { - var showAddContactSheet by remember { mutableStateOf(false) } + var showAddContactSheet by rememberSaveable { mutableStateOf(openAddContactSheet) } ScreenColumn { AppTopBar( @@ -106,7 +111,7 @@ private fun Content( ActionButton( onClick = { showAddContactSheet = true }, iconRes = R.drawable.ic_plus, - modifier = Modifier.testTag("ContactsAddButton"), + modifier = Modifier.testTag("ContactsAddButton") ) } VerticalSpacer(8.dp) @@ -163,8 +168,8 @@ private fun ContactsList( ) ContactRow( profile = myProfile, - modifier = Modifier.testTag("ContactsMyProfile"), onClick = onClickMyProfile, + modifier = Modifier.testTag("ContactsMyProfile") ) HorizontalDivider() } @@ -183,8 +188,8 @@ private fun ContactsList( items(contacts, key = { it.publicKey }) { contact -> ContactRow( profile = contact, - modifier = Modifier.testTag("Contact_${contact.publicKey}"), onClick = { onClickContact(contact.publicKey) }, + modifier = Modifier.testTag("Contact_${contact.publicKey}") ) HorizontalDivider() } @@ -208,14 +213,21 @@ private fun ContactRow( ) { ContactAvatar(profile = profile) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { BodyS( text = profile.truncatedPublicKey, color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) BodySSB( text = profile.name, color = Colors.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -258,13 +270,12 @@ private fun EmptyState( onAddContact: () -> Unit, ) { Column( - verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp) ) { - VerticalSpacer(16.dp) myProfile?.let { + VerticalSpacer(16.dp) Text13Up( text = stringResource(R.string.contacts__my_profile), color = Colors.White64, @@ -272,21 +283,32 @@ private fun EmptyState( ) ContactRow( profile = it, - modifier = Modifier.testTag("ContactsMyProfile"), onClick = onClickMyProfile, + modifier = Modifier.testTag("ContactsMyProfile") ) HorizontalDivider() } - VerticalSpacer(8.dp) - PrimaryButton( - text = stringResource(R.string.contacts__intro_add_contact), - onClick = onAddContact, - modifier = Modifier.testTag("ContactsEmptyAddButton"), - ) - BodyM( - text = stringResource(R.string.contacts__empty_state), - color = Colors.White64, - ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 48.dp) + ) { + PrimaryButton( + text = stringResource(R.string.contacts__intro_add_contact), + onClick = onAddContact, + modifier = Modifier.testTag("ContactsEmptyAddButton") + ) + BodyM( + text = stringResource(R.string.contacts__empty_state), + color = Colors.White64, + ) + } + + FillHeight() } } @@ -310,6 +332,7 @@ private fun Preview() { onSearchTextChange = {}, onAddContact = {}, onScanQr = {}, + openAddContactSheet = false, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt index efacdb771..d47d70cbe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt @@ -127,7 +127,8 @@ private fun Content( ) }, publicKeyLabel = stringResource(R.string.contacts__pubky), - showFooterNote = false, + bioPlaceholder = stringResource(R.string.contacts__edit_bio_placeholder), + footerNote = stringResource(R.string.contacts__edit_public_note), onDelete = onDelete, deleteLabel = stringResource(R.string.contacts__delete_contact), ) @@ -137,7 +138,7 @@ private fun Content( if (uiState.showDeleteDialog) { AppAlertDialog( title = stringResource(R.string.contacts__delete_confirm_title, uiState.name), - text = stringResource(R.string.contacts__delete_confirm_text), + text = stringResource(R.string.contacts__delete_confirm_text, uiState.name), confirmText = stringResource(R.string.common__delete_yes), onConfirm = onConfirmDelete, onDismiss = onDismissDeleteDialog, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt index 140fac204..7edf27c5a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt @@ -199,6 +199,7 @@ class EditContactViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.SUCCESS, title = context.getString(R.string.contacts__edit_contact_saved), + testTag = "ContactUpdatedToast", ) _effects.emit(EditContactEffect.SaveSuccess) }.onFailure { @@ -213,6 +214,11 @@ class EditContactViewModel @Inject constructor( _uiState.update { it.copy(showDeleteDialog = false, isSaving = true) } pubkyRepo.removeContact(publicKey) .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.contacts__delete_success), + testTag = "ContactDeletedToast", + ) _effects.emit(EditContactEffect.DeleteSuccess) } .onFailure { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt index afb9d78a7..ea4e412f4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import kotlinx.collections.immutable.persistentListOf @@ -44,7 +45,7 @@ import to.bitkit.ui.theme.Colors fun EditProfileScreen( viewModel: EditProfileViewModel, onBackClick: () -> Unit, - onProfileDeleted: () -> Unit, + onExitProfile: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -52,7 +53,9 @@ fun EditProfileScreen( viewModel.effects.collect { when (it) { EditProfileEffect.SaveSuccess -> onBackClick() - EditProfileEffect.DeleteSuccess -> onProfileDeleted() + EditProfileEffect.DeleteSuccess, + EditProfileEffect.DisconnectSuccess, + -> onExitProfile() } } } @@ -72,6 +75,9 @@ fun EditProfileScreen( onDelete = viewModel::showDeleteConfirmation, onDismissDeleteDialog = viewModel::dismissDeleteDialog, onConfirmDelete = viewModel::deleteProfile, + onRetryDelete = viewModel::retryDeleteProfile, + onDismissDeleteFailureDialog = viewModel::dismissDeleteFailureDialog, + onDisconnectProfile = viewModel::disconnectProfile, onDismissAddLinkSheet = viewModel::dismissAddLinkSheet, onSaveLink = viewModel::addLink, onDismissAddTagSheet = viewModel::dismissAddTagSheet, @@ -96,6 +102,9 @@ private fun Content( onDelete: () -> Unit, onDismissDeleteDialog: () -> Unit, onConfirmDelete: () -> Unit, + onRetryDelete: () -> Unit, + onDismissDeleteFailureDialog: () -> Unit, + onDisconnectProfile: () -> Unit, onDismissAddLinkSheet: () -> Unit, onSaveLink: (String, String) -> Unit, onDismissAddTagSheet: () -> Unit, @@ -171,6 +180,22 @@ private fun Content( ) } + if (uiState.showDeleteFailureDialog) { + AppAlertDialog( + title = stringResource(R.string.profile__delete_error_title), + text = stringResource(R.string.profile__delete_error_description), + confirmText = stringResource(R.string.common__retry), + dismissText = stringResource(R.string.profile__sign_out), + onConfirm = onRetryDelete, + onDismiss = onDisconnectProfile, + onDismissRequest = onDismissDeleteFailureDialog, + properties = DialogProperties( + dismissOnClickOutside = true, + dismissOnBackPress = true, + ), + ) + } + if (uiState.showAddLinkSheet) { AddLinkSheet( onDismiss = onDismissAddLinkSheet, @@ -199,7 +224,7 @@ private fun AvatarSection( .clip(CircleShape) .background(Colors.Gray5) .testTag("EditProfileAvatar") - .clickable(onClick = onClick), + .clickable(onClick = onClick) ) { when { newAvatarUri != null -> AsyncImage( @@ -243,6 +268,9 @@ private fun Preview() { onDelete = {}, onDismissDeleteDialog = {}, onConfirmDelete = {}, + onRetryDelete = {}, + onDismissDeleteFailureDialog = {}, + onDisconnectProfile = {}, onDismissAddLinkSheet = {}, onSaveLink = { _, _ -> }, onDismissAddTagSheet = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index c2350a598..c511627fc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -208,27 +208,67 @@ class EditProfileViewModel @Inject constructor( fun deleteProfile() { viewModelScope.launch { - _uiState.update { it.copy(showDeleteDialog = false, isSaving = true) } - pubkyRepo.deleteProfile() + attemptDeleteProfile() + } + } + + fun retryDeleteProfile() { + viewModelScope.launch { + attemptDeleteProfile() + } + } + + fun dismissDeleteFailureDialog() { + _uiState.update { it.copy(showDeleteFailureDialog = false) } + } + + fun disconnectProfile() { + viewModelScope.launch { + _uiState.update { it.copy(showDeleteFailureDialog = false, isSaving = true) } + pubkyRepo.signOut() .onSuccess { _uiState.update { it.copy(isSaving = false) } - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.profile__delete_success), - ) - _effects.emit(EditProfileEffect.DeleteSuccess) + _effects.emit(EditProfileEffect.DisconnectSuccess) } .onFailure { - Logger.error("Failed to delete profile", it, context = TAG) + Logger.error("Failed to disconnect profile", it, context = TAG) _uiState.update { it.copy(isSaving = false) } ToastEventBus.send( type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__delete_error), + title = context.getString(R.string.profile__disconnect_error), description = it.message, ) } } } + + private suspend fun attemptDeleteProfile() { + _uiState.update { + it.copy( + showDeleteDialog = false, + showDeleteFailureDialog = false, + isSaving = true, + ) + } + pubkyRepo.deleteProfileWithSessionRetry() + .onSuccess { + _uiState.update { it.copy(isSaving = false) } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.profile__delete_success), + ) + _effects.emit(EditProfileEffect.DeleteSuccess) + } + .onFailure { + Logger.error("Failed to delete profile", it, context = TAG) + _uiState.update { + it.copy( + isSaving = false, + showDeleteFailureDialog = true, + ) + } + } + } } @Stable @@ -244,6 +284,7 @@ data class EditProfileUiState( val isLoading: Boolean = false, val isSaving: Boolean = false, val showDeleteDialog: Boolean = false, + val showDeleteFailureDialog: Boolean = false, val showAddLinkSheet: Boolean = false, val showAddTagSheet: Boolean = false, ) @@ -251,4 +292,5 @@ data class EditProfileUiState( sealed interface EditProfileEffect { data object SaveSuccess : EditProfileEffect data object DeleteSuccess : EditProfileEffect + data object DisconnectSuccess : EditProfileEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 7e9a4167d..85099ff75 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -46,6 +46,7 @@ import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.rememberDebouncedClick import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppThemeSurface @@ -154,12 +155,14 @@ private fun ProfileBody( Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .clickableAlpha(onClick = onClickCopy) ) { QrCodeImage( content = profile.publicKey, modifier = Modifier.fillMaxWidth(), - testTag = "QRCode", + testTag = "ProfileQRCode", ) if (profile.imageUrl != null) { Box( @@ -214,7 +217,7 @@ private fun ProfileBody( color = Colors.White64, modifier = Modifier .fillMaxWidth() - .testTag("ProfileViewTagsHeader"), + .testTag("ProfileViewTagsHeader") ) VerticalSpacer(8.dp) @OptIn(ExperimentalLayoutApi::class) @@ -260,12 +263,12 @@ private fun EmptyState( SecondaryButton( text = stringResource(R.string.profile__retry_load), onClick = onClickRetry, - modifier = Modifier.testTag("ProfileRetry"), + modifier = Modifier.testTag("ProfileRetry") ) VerticalSpacer(8.dp) TextButton( onClick = rememberDebouncedClick(onClick = onClickSignOut), - modifier = Modifier.testTag("ProfileEmptySignOut"), + modifier = Modifier.testTag("ProfileEmptySignOut") ) { BodyS(text = stringResource(R.string.profile__sign_out), color = Colors.White64) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8ae385658..46d973105 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -91,15 +91,17 @@ import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue import to.bitkit.ext.watchUntil import to.bitkit.models.FeeRate -import to.bitkit.models.msatFloorOf import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork @@ -1037,7 +1039,12 @@ class AppViewModel @Inject constructor( ) } - private fun launchScan(source: ScanSource, data: String, startDelay: Duration = Duration.ZERO) { + private fun launchScan( + source: ScanSource, + data: String, + startDelay: Duration = Duration.ZERO, + routePubkyKeys: Boolean = false, + ) { val normalized = data.removeLightningSchemes() val scanId = if (data.length > 24) "${data.take(11)}…${data.takeLast(11)}" else data @@ -1055,7 +1062,7 @@ class AppViewModel @Inject constructor( Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) activeScanJob = viewModelScope.launch { if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data) + handleScan(data, routePubkyKeys) }.also { it.invokeOnCompletion { if (activeScanInput == normalized) activeScanInput = null } } } @@ -1274,11 +1281,23 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToScan) } - fun onScanResult(data: String, startDelay: Duration = Duration.ZERO) { - launchScan(source = ScanSource.SCAN_RESULT, data = data, startDelay = startDelay) + fun onScanResult( + data: String, + startDelay: Duration = Duration.ZERO, + routePubkyKeys: Boolean = false, + ) { + launchScan( + source = ScanSource.SCAN_RESULT, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + ) } - private suspend fun handleScan(result: String) = withContext(bgDispatcher) { + private suspend fun handleScan( + result: String, + routePubkyKeys: Boolean, + ) = withContext(bgDispatcher) { // always reset state on new scan resetSendState() resetQuickPay() @@ -1301,6 +1320,19 @@ class AppViewModel @Inject constructor( return@withContext } + if (routePubkyKeys) { + val route = resolvePastedPubkyRoute( + input = input, + ownPublicKey = pubkyRepo.publicKey.value, + contacts = pubkyRepo.contacts.value, + ) + + if (route != null) { + mainScreenEffect(MainScreenEffect.Navigate(route)) + return@withContext + } + } + val scan = runCatching { coreService.decode(input) } .onFailure { Logger.error("Failed to decode scan data: '$input'", it, context = TAG) } .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } @@ -2259,7 +2291,12 @@ class AppViewModel @Inject constructor( handler(data) } } else { - launchScan(source = ScanSource.SCANNER_SHEET, data = data, startDelay = SCREEN_TRANSITION_DELAY) + launchScan( + source = ScanSource.SCANNER_SHEET, + data = data, + startDelay = SCREEN_TRANSITION_DELAY, + routePubkyKeys = true, + ) } } @@ -2689,3 +2726,21 @@ sealed interface QuickPayData { data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData } // endregion + +internal fun resolvePastedPubkyRoute( + input: String, + ownPublicKey: String?, + contacts: List, +): Routes? { + val normalizedKey = PubkyPublicKeyFormat.normalized(input) ?: return null + + if (PubkyPublicKeyFormat.matches(normalizedKey, ownPublicKey)) { + return Routes.Profile + } + + if (contacts.any { PubkyPublicKeyFormat.matches(it.publicKey, normalizedKey) }) { + return Routes.ContactDetail(normalizedKey) + } + + return Routes.AddContact(normalizedKey) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 1044d1ee6..f2de88e1e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -110,6 +110,8 @@ class SettingsViewModel @Inject constructor( } val isPubkyAuthenticated = pubkyRepo.isAuthenticated + val hasPubkyContacts = pubkyRepo.contacts.map { it.isNotEmpty() } + .asStateFlow(initialValue = false) val quickPayIntroSeen = settingsStore.data.map { it.quickPayIntroSeen } .asStateFlow(initialValue = false) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index feea82769..aae350dc6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -33,6 +33,7 @@ import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo @@ -56,6 +57,7 @@ class WalletViewModel @Inject constructor( private val settingsStore: SettingsStore, private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, + private val pubkyRepo: PubkyRepo, private val migrationService: MigrationService, private val connectivityRepo: ConnectivityRepo, ) : ViewModel() { @@ -212,6 +214,8 @@ class WalletViewModel @Inject constructor( } else { backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) } + + pubkyRepo.initialize() } private suspend fun restoreFromRNRemoteBackup() = runCatching { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20c91cd2b..f00160f9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,13 +84,16 @@ Scan QR Add Contact CONTACTS - This contact will be removed from your list. + Are you sure you want to delete %1$s from your contacts? Delete %1$s? Delete Contact + Contact deleted Unable to load contact. Contact + Short note about this contact. Contact updated Edit Contact + Please note contact information is stored in public files. Changes you make to a contact in Bitkit will not update their profile. You don\'t have any contacts yet. Import All %1$d friends @@ -508,18 +511,20 @@ Create Profile Restoring your existing profile… Profile created successfully - This will delete your current Pubky profile data. You can create a new profile for this pubky later. + Are you sure you want to delete all of your profile information for your pubky? Delete Profile? - Failed to delete profile + We couldn\'t delete your profile data. Retry, or disconnect this pubky from Bitkit. + Unable to Delete Profile Delete Profile Profile deleted Deriving your keys… + Failed to disconnect profile NOTES Short note. Tell a bit about yourself. DELETE YOUR NAME Edit Profile - Please note that all your profile information will be publicly available and visible. + Please note profile information is stored in public files. Changes you make in Bitkit will not update your pubky.app profile. Failed to save profile Profile saved TAGS diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 664b6bfb4..67acbd04f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -22,8 +22,11 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkySessionBackupKind +import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -32,6 +35,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds import com.synonym.bitkitcore.PubkyProfile as CorePubkyProfile +@Suppress("LargeClass") class PubkyRepoTest : BaseUnitTest() { companion object { // Valid 52-char z-base-32 key (+ "pubky" prefix = 57 chars) @@ -81,7 +85,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `startAuthentication should reset state on failure`() = test { - whenever(pubkyService.startAuth()).thenThrow(RuntimeException("Auth failed")) + whenever(pubkyService.startAuth()).thenAnswer { throw TestAppError("Auth failed") } val result = sut.startAuthentication() @@ -94,30 +98,30 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `completeAuthentication should save session and update state`() = test { val testSecret = "session_secret" - val testPk = "completed_pk" + val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = mock() whenever(ffiProfile.name).thenReturn("User") - whenever(pubkyService.getProfile(testPk)).thenReturn(ffiProfile) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) val result = sut.completeAuthentication() assertTrue(result.isSuccess) - assertEquals(testPk, sut.publicKey.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertTrue(sut.isAuthenticated.value) - verifyBlocking(keychain) { saveString(Keychain.Key.PAYKIT_SESSION.name, testSecret) } + verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, testSecret) } } @Test fun `completeAuthentication should clear managed secret key`() = test { val testSecret = "session_secret" - val testPk = "completed_pk" + val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = createFfiProfile(name = "User") - whenever(pubkyService.getProfile(testPk)).thenReturn(ffiProfile) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) val result = sut.completeAuthentication() @@ -128,11 +132,11 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `completeAuthentication should not load contacts automatically`() = test { val testSecret = "session_secret" - val testPk = "completed_pk" + val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = createFfiProfile(name = "User") - whenever(pubkyService.getProfile(testPk)).thenReturn(ffiProfile) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) val result = sut.completeAuthentication() @@ -142,7 +146,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `completeAuthentication should reset state on failure`() = test { - whenever(pubkyService.completeAuth()).thenThrow(RuntimeException("Failed")) + whenever(pubkyService.completeAuth()).thenAnswer { throw TestAppError("Failed") } val result = sut.completeAuthentication() @@ -190,7 +194,7 @@ class PubkyRepoTest : BaseUnitTest() { assertNotNull(existingProfile) val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" } - whenever(pubkyService.getProfile(pk)).thenThrow(RuntimeException("Network error")) + whenever(pubkyService.getProfile(pk)).thenAnswer { throw TestAppError("Network error") } sut.loadProfile() @@ -224,6 +228,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `signOut should clear state and keychain`() = test { authenticateForTesting() + clearInvocations(pubkyStore) val result = sut.signOut() @@ -262,18 +267,64 @@ class PubkyRepoTest : BaseUnitTest() { fun `deleteProfile should fail when signOut fails`() = test { authenticateForTesting() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") - whenever(pubkyService.signOut()).thenThrow(RuntimeException("Sign out failed")) - whenever(pubkyService.forceSignOut()).thenThrow(RuntimeException("Force sign out failed")) + whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Sign out failed") } + whenever(pubkyService.forceSignOut()).thenAnswer { throw TestAppError("Force sign out failed") } val result = sut.deleteProfile() assertTrue(result.isFailure) } + @Test + fun `deleteProfileWithSessionRetry should refresh session and retry delete`() = test { + val expiredSession = "expired_session" + val newSession = "new_session" + val secretKey = "local_secret" + authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession, newSession) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.sessionList(expiredSession, Env.contactsBasePath)).thenReturn(emptyList()) + whenever(pubkyService.sessionList(newSession, Env.contactsBasePath)).thenReturn(emptyList()) + whenever( + pubkyService.sessionDelete(expiredSession, Env.profilePath) + ).thenAnswer { + throw TestAppError("Expired") + } + whenever(pubkyService.signIn(secretKey)).thenReturn(newSession) + whenever(pubkyService.importSession(newSession)).thenReturn(VALID_SELF_KEY) + + val result = sut.deleteProfileWithSessionRetry() + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { sessionDelete(expiredSession, Env.profilePath) } + verifyBlocking(pubkyService) { sessionDelete(newSession, Env.profilePath) } + verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) } + } + + @Test + fun `deleteProfileWithSessionRetry should return failure when session cannot refresh`() = test { + val expiredSession = "expired_session" + authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(pubkyService.sessionList(expiredSession, Env.contactsBasePath)).thenReturn(emptyList()) + whenever( + pubkyService.sessionDelete(expiredSession, Env.profilePath) + ).thenAnswer { + throw TestAppError("Expired") + } + + val result = sut.deleteProfileWithSessionRetry() + + assertTrue(result.isFailure) + verifyBlocking(pubkyService) { sessionDelete(expiredSession, Env.profilePath) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + } + @Test fun `signOut should force sign out when server sign out fails`() = test { authenticateForTesting() - whenever(pubkyService.signOut()).thenThrow(RuntimeException("Server error")) + whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val result = sut.signOut() @@ -339,6 +390,165 @@ class PubkyRepoTest : BaseUnitTest() { } } + @Test + fun `snapshotSessionBackupState should prefer local seed over session secret`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret") + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session_secret") + + val result = sut.snapshotSessionBackupState() + + assertEquals( + PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed), + result.getOrNull(), + ) + } + + @Test + fun `snapshotSessionBackupState should use external session when no local seed exists`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session_secret") + + val result = sut.snapshotSessionBackupState() + + assertEquals( + PubkySessionBackupV1( + kind = PubkySessionBackupKind.ExternalSession, + sessionSecret = "session_secret", + ), + result.getOrNull(), + ) + } + + @Test + fun `snapshotSessionBackupState should return null when no pubky credentials exist`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) + + val result = sut.snapshotSessionBackupState() + + assertNull(result.getOrNull()) + } + + @Test + fun `initialize should restore saved session with prefixed public key`() = test { + val session = "saved_session" + val unprefixedPublicKey = VALID_SELF_KEY.removePrefix("pubky") + val ffiProfile = createFfiProfile(name = "Restored User") + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + + sut.initialize() + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + } + + @Test + fun `initialize should restore session from local secret key when saved session is missing`() = test { + val secretKey = "local_secret" + val session = "new_session" + val publicKey = VALID_SELF_KEY.removePrefix("pubky") + val ffiProfile = createFfiProfile(name = "Recovered User") + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.signIn(secretKey)).thenReturn(session) + whenever(pubkyService.importSession(session)).thenReturn(publicKey) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + + sut.initialize() + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, session) } + } + + @Test + fun `initialize should delete stale saved session when re-sign-in is unavailable`() = test { + val session = "stale_session" + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") } + + sut.initialize() + + assertTrue(sut.sessionRestorationFailed.value) + assertFalse(sut.isAuthenticated.value) + verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + } + + @Test + fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { + val secretKey = "local_secret" + val session = "new_session" + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.signIn(secretKey)).thenReturn(session) + whenever(pubkyService.importSession(session)).thenReturn(VALID_SELF_KEY) + + val result = sut.refreshSessionIfPossible() + + assertEquals(true, result.getOrNull()) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, session) } + } + + @Test + fun `refreshSessionIfPossible should return false when local secret key is missing`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + + val result = sut.refreshSessionIfPossible() + + assertEquals(false, result.getOrNull()) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + } + + @Test + fun `restoreSessionBackupState should derive local secret key for local seed backups`() = test { + val seed = byteArrayOf(1, 2, 3) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) + whenever(pubkyService.mnemonicToSeed("test mnemonic", null)).thenReturn(seed) + whenever(pubkyService.deriveSecretKey(seed)).thenReturn("derived_secret") + + val result = sut.restoreSessionBackupState( + PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed), + ) + + assertTrue(result.isSuccess) + verifyBlocking(keychain) { upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, "derived_secret") } + } + + @Test + fun `restoreSessionBackupState should save external session backups`() = test { + val result = sut.restoreSessionBackupState( + PubkySessionBackupV1( + kind = PubkySessionBackupKind.ExternalSession, + sessionSecret = "external_session", + ), + ) + + assertTrue(result.isSuccess) + verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, "external_session") } + } + + @Test + fun `restoreSessionBackupState should keep current session when backup has no pubky state`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + clearInvocations(pubkyService, keychain) + + val result = sut.restoreSessionBackupState(null) + + assertTrue(result.isSuccess) + assertTrue(sut.isAuthenticated.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(pubkyService, never()) { forceSignOut() } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + @Test fun `loadContacts should populate contacts on success`() = test { authenticateForTesting() @@ -403,14 +613,14 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(newSecret) whenever(pubkyService.sessionList(newSecret, Env.contactsBasePath)).thenReturn(emptyList()) val staleProfile = createFfiProfile(name = "Stale Old") - whenever(pubkyService.getProfile(oldPublicKey)).thenAnswer { + whenever(pubkyService.getProfile(oldPublicKey.ensurePubkyPrefixForTest())).thenAnswer { runBlocking { sut.completeAuthentication() } staleProfile } sut.loadProfile() - assertEquals(newPublicKey, sut.publicKey.value) + assertEquals(newPublicKey.ensurePubkyPrefixForTest(), sut.publicKey.value) assertEquals("Initial Old", sut.profile.value?.name) } @@ -443,7 +653,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.completeAuth()).thenReturn(newSecret) whenever(pubkyService.importSession(newSecret)).thenReturn(newPublicKey) val newProfile = createFfiProfile(name = "New User") - whenever(pubkyService.getProfile(newPublicKey)).thenReturn(newProfile) + whenever(pubkyService.getProfile(newPublicKey.ensurePubkyPrefixForTest())).thenReturn(newProfile) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(oldSecret) whenever(pubkyService.sessionList(oldSecret, Env.contactsBasePath)).thenReturn(listOf(staleContactPath)) whenever(pubkyService.fetchFileString(staleContactUri)).thenAnswer { @@ -454,7 +664,7 @@ class PubkyRepoTest : BaseUnitTest() { sut.loadContacts() val contacts = sut.contacts.value - assertEquals(newPublicKey, sut.publicKey.value) + assertEquals(newPublicKey.ensurePubkyPrefixForTest(), sut.publicKey.value) assertEquals(1, contacts.size) assertEquals(existingContact.publicKey, contacts.first().publicKey) assertEquals(existingContact.name, contacts.first().name) @@ -479,7 +689,7 @@ class PubkyRepoTest : BaseUnitTest() { val pk = checkNotNull(sut.publicKey.value) val strippedPk = pk.removePrefix("pubky") whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$contactKey")) - .thenThrow(RuntimeException("Network error")) + .thenAnswer { throw TestAppError("Network error") } sut.loadContacts() @@ -494,7 +704,7 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath)) - .thenThrow(RuntimeException("Directory Not Found (404)")) + .thenAnswer { throw TestAppError("Directory Not Found (404)") } sut.loadContacts() @@ -523,7 +733,7 @@ class PubkyRepoTest : BaseUnitTest() { val strippedKey = contactKey.removePrefix("pubky") val contactProfile = mock() whenever(pubkyService.fetchFileString("pubky://$strippedKey${Env.profilePath}")) - .thenThrow(RuntimeException("Missing bitkit profile")) + .thenAnswer { throw TestAppError("Missing bitkit profile") } whenever(contactProfile.name).thenReturn("Bob") whenever(contactProfile.bio).thenReturn("Bio") whenever(pubkyService.getProfile(contactKey)).thenReturn(contactProfile) @@ -539,8 +749,8 @@ class PubkyRepoTest : BaseUnitTest() { val contactKey = VALID_CONTACT_KEY_A val strippedKey = contactKey.removePrefix("pubky") whenever(pubkyService.fetchFileString("pubky://$strippedKey${Env.profilePath}")) - .thenThrow(RuntimeException("Missing bitkit profile")) - whenever(pubkyService.getProfile(contactKey)).thenThrow(RuntimeException("Profile not found")) + .thenAnswer { throw TestAppError("Missing bitkit profile") } + whenever(pubkyService.getProfile(contactKey)).thenAnswer { throw TestAppError("Profile not found") } val result = sut.fetchContactProfile(contactKey) @@ -650,6 +860,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `wipeLocalState should clear pubky state without server sign out`() = test { authenticateForTesting() + clearInvocations(pubkyStore) val contact = PubkyProfile( publicKey = "pubkycontact4", name = "Charlie", @@ -698,10 +909,11 @@ class PubkyRepoTest : BaseUnitTest() { secret: String = "test_secret", profileName: String = "Test", ) { + val prefixedPublicKey = publicKey.ensurePubkyPrefixForTest() whenever { pubkyService.completeAuth() }.thenReturn(secret) whenever { pubkyService.importSession(secret) }.thenReturn(publicKey) val ffiProfile = createFfiProfile(name = profileName) - whenever { pubkyService.getProfile(publicKey) }.thenReturn(ffiProfile) + whenever { pubkyService.getProfile(prefixedPublicKey) }.thenReturn(ffiProfile) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(secret) whenever { pubkyService.sessionList(secret, Env.contactsBasePath) }.thenReturn(emptyList()) @@ -717,3 +929,8 @@ class PubkyRepoTest : BaseUnitTest() { return ffiProfile } } + +private class TestAppError(message: String) : AppError(message) + +private fun String.ensurePubkyPrefixForTest(): String = + if (startsWith("pubky")) this else "pubky$this" diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 01f0af3cc..29347acce 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle -import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.PeerDetails @@ -14,6 +13,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.ext.of @@ -24,6 +24,7 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -31,6 +32,7 @@ import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel +import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class WalletViewModelTest : BaseUnitTest() { @@ -42,6 +44,7 @@ class WalletViewModelTest : BaseUnitTest() { private val settingsStore = mock() private val backupRepo = mock() private val blocktankRepo = mock() + private val pubkyRepo = mock() private val migrationService = mock() private val connectivityRepo = mock() @@ -59,6 +62,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(migrationService.isMigrationChecked()).thenReturn(true) whenever(migrationService.isChannelRecoveryChecked()).thenReturn(true) whenever(migrationService.tryFetchMigrationPeersFromBackup()).thenReturn(emptyList()) + whenever { migrationService.getRNRemoteBackupTimestamp() }.thenReturn(null) whenever(connectivityRepo.isOnline).thenReturn(isOnline) sut = WalletViewModel( @@ -69,6 +73,7 @@ class WalletViewModelTest : BaseUnitTest() { settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, ) @@ -190,6 +195,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `onBackupRestoreSuccess should reset restoreState`() = test { + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) walletState.value = walletState.value.copy(walletExists = true) sut.restoreWallet("mnemonic", "passphrase") @@ -203,6 +209,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `onProceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) @@ -215,6 +222,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `restore state should transition as expected`() = test { + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) assertEquals(RestoreState.Initial, sut.restoreState.value) @@ -228,6 +236,18 @@ class WalletViewModelTest : BaseUnitTest() { assertEquals(RestoreState.Settled, sut.restoreState.value) } + @Test + fun `backup restore should reinitialize pubky state after metadata restore`() = test { + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) + + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + + verifyBlocking(pubkyRepo) { initialize() } + } + @Test fun `start should call refreshBip21 when restore state is idle`() = test { // Create fresh mocks for this test @@ -264,6 +284,7 @@ class WalletViewModelTest : BaseUnitTest() { settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, ) @@ -324,6 +345,7 @@ class WalletViewModelTest : BaseUnitTest() { settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, ) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt new file mode 100644 index 000000000..da4b5c398 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -0,0 +1,44 @@ +package to.bitkit.ui.screens.contacts + +import org.junit.Test +import kotlin.test.assertEquals + +class ContactImportFlowTest { + private companion object { + const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + } + + @Test + fun `resolveAddContactValidation returns empty for blank input`() { + assertEquals( + AddContactValidationResult.Empty, + resolveAddContactValidation(input = " ", ownPublicKey = null), + ) + } + + @Test + fun `resolveAddContactValidation returns invalid key for bad input`() { + assertEquals( + AddContactValidationResult.InvalidKey, + resolveAddContactValidation(input = "pubkyinvalid", ownPublicKey = null), + ) + } + + @Test + fun `resolveAddContactValidation returns own key for self`() { + assertEquals( + AddContactValidationResult.OwnKey, + resolveAddContactValidation(input = VALID_PUBLIC_KEY, ownPublicKey = VALID_PUBLIC_KEY), + ) + } + + @Test + fun `resolveAddContactValidation returns normalized key for valid input`() { + val rawKey = VALID_PUBLIC_KEY.removePrefix("pubky") + + assertEquals( + AddContactValidationResult.Valid(normalizedKey = VALID_PUBLIC_KEY), + resolveAddContactValidation(input = rawKey, ownPublicKey = null), + ) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index 38d9ef903..24cc33e71 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt @@ -1,38 +1,141 @@ package to.bitkit.ui.screens.profile import android.content.Context +import app.cash.turbine.test import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PubkyRepo 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 EditProfileViewModelTest : BaseUnitTest() { + companion object { + private const val TEST_PUBLIC_KEY = "pubkyalice" + } + private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() @Test fun `updateLinkUrl should update existing profile link`() = test { + val sut = createSut() + advanceUntilIdle() + + sut.updateLinkUrl(0, "https://updated.example.com") + + assertEquals("https://updated.example.com", sut.uiState.value.links.first().url) + } + + @Test + fun `deleteProfile should emit success when repository delete succeeds`() = test { + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit)) + + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.deleteProfile() + advanceUntilIdle() + + assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) + } + assertFalse(sut.uiState.value.showDeleteFailureDialog) + verify(pubkyRepo).deleteProfileWithSessionRetry() + } + + @Test + fun `retryDeleteProfile should emit success when repository delete succeeds`() = test { + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit)) + + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.retryDeleteProfile() + advanceUntilIdle() + + assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) + } + assertFalse(sut.uiState.value.showDeleteFailureDialog) + verify(pubkyRepo).deleteProfileWithSessionRetry() + } + + @Test + fun `deleteProfile should show retry dialog when delete still fails`() = test { + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( + Result.failure(TestAppError("expired session")), + ) + + val sut = createSut() + advanceUntilIdle() + + sut.deleteProfile() + advanceUntilIdle() + + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertFalse(sut.uiState.value.isSaving) + } + + @Test + fun `disconnectProfile should emit disconnect success`() = test { + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( + Result.failure(TestAppError("expired session")), + ) + whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) + + val sut = createSut() + advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() + + sut.effects.test { + sut.disconnectProfile() + advanceUntilIdle() + + assertEquals(EditProfileEffect.DisconnectSuccess, awaitItem()) + } + assertFalse(sut.uiState.value.showDeleteFailureDialog) + verify(pubkyRepo).signOut() + } + + @Test + fun `dismissDeleteFailureDialog should hide retry dialog`() = test { + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( + Result.failure(TestAppError("expired session")), + ) + + val sut = createSut() + advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() + + sut.dismissDeleteFailureDialog() + + assertFalse(sut.uiState.value.showDeleteFailureDialog) + } + + private fun createSut(): EditProfileViewModel { + whenever(context.getString(any())).thenReturn("") whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(createProfile())) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY)) - val sut = EditProfileViewModel( + return EditProfileViewModel( context = context, pubkyRepo = pubkyRepo, ) - advanceUntilIdle() - - sut.updateLinkUrl(0, "https://updated.example.com") - - assertEquals("https://updated.example.com", sut.uiState.value.links.first().url) } private fun createProfile() = PubkyProfile( @@ -44,8 +147,6 @@ class EditProfileViewModelTest : BaseUnitTest() { tags = listOf("friend").toImmutableList(), status = null, ) - - companion object { - private const val TEST_PUBLIC_KEY = "pubkyalice" - } } + +private class TestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt b/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt new file mode 100644 index 000000000..e451fd4b0 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt @@ -0,0 +1,61 @@ +package to.bitkit.viewmodels + +import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.Routes +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PubkyRouteResolverTest { + companion object { + private const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + private const val OTHER_VALID_PUBLIC_KEY = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" + } + + @Test + fun `resolvePastedPubkyRoute returns profile for own key`() { + assertEquals( + Routes.Profile, + resolvePastedPubkyRoute( + input = VALID_PUBLIC_KEY, + ownPublicKey = VALID_PUBLIC_KEY, + contacts = emptyList(), + ), + ) + } + + @Test + fun `resolvePastedPubkyRoute returns contact detail for existing contact`() { + assertEquals( + Routes.ContactDetail(VALID_PUBLIC_KEY), + resolvePastedPubkyRoute( + input = VALID_PUBLIC_KEY, + ownPublicKey = OTHER_VALID_PUBLIC_KEY, + contacts = listOf(PubkyProfile.placeholder(VALID_PUBLIC_KEY)), + ), + ) + } + + @Test + fun `resolvePastedPubkyRoute returns add contact for unknown key`() { + assertEquals( + Routes.AddContact(VALID_PUBLIC_KEY), + resolvePastedPubkyRoute( + input = VALID_PUBLIC_KEY, + ownPublicKey = OTHER_VALID_PUBLIC_KEY, + contacts = emptyList(), + ), + ) + } + + @Test + fun `resolvePastedPubkyRoute returns null for invalid input`() { + assertNull( + resolvePastedPubkyRoute( + input = "not-a-pubky", + ownPublicKey = null, + contacts = emptyList(), + ), + ) + } +}