From bd77518520dfb54ae05c7c8b7ec21bbd9e7c25b9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 07:52:24 -0500 Subject: [PATCH 01/20] fix: polish pubky profile flows --- CHANGELOG.md | 3 + .../java/to/bitkit/models/BackupPayloads.kt | 17 ++ .../java/to/bitkit/repositories/BackupRepo.kt | 43 +++- .../java/to/bitkit/repositories/PubkyRepo.kt | 234 +++++++++++++----- app/src/main/java/to/bitkit/ui/ContentView.kt | 12 +- .../bitkit/ui/components/ProfileEditForm.kt | 46 +++- .../ui/screens/contacts/AddContactScreen.kt | 26 +- .../screens/contacts/ContactDetailScreen.kt | 2 +- .../ui/screens/contacts/ContactImportFlow.kt | 46 ++++ .../ui/screens/contacts/ContactsScreen.kt | 35 ++- .../ui/screens/contacts/EditContactScreen.kt | 5 +- .../ui/screens/profile/EditProfileScreen.kt | 32 ++- .../screens/profile/EditProfileViewModel.kt | 74 +++++- .../ui/screens/profile/ProfileScreen.kt | 5 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 50 +++- .../to/bitkit/viewmodels/WalletViewModel.kt | 4 + app/src/main/res/values/strings.xml | 10 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 121 ++++++++- .../java/to/bitkit/ui/WalletViewModelTest.kt | 22 ++ .../screens/contacts/ContactImportFlowTest.kt | 95 +++++++ .../profile/EditProfileViewModelTest.kt | 94 ++++++- 21 files changed, 842 insertions(+), 134 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 299beaf0f..1e8632f2c 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] +### Added +- Improve Pubky profile restore, contact editing, and contact routing flows + ## [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..448c45f2b 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 { + val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) + val cacheData = cacheStore.data.first() + val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrThrow() + + val payload = MetadataBackupV1( + createdAt = currentTimeMillis(), + tagMetadata = preActivityMetadata, + cache = cacheData, + pubkySession = pubkySession, + ) + + return 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..3f9a4f58d 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,6 +69,7 @@ class PubkyRepo @Inject constructor( } private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val initializeMutex = Mutex() private val loadProfileMutex = Mutex() private val loadContactsMutex = Mutex() @@ -95,6 +99,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,66 +125,90 @@ class PubkyRepo @Inject constructor( // region Initialization - private suspend fun initialize() { - val result = runCatching { - withContext(ioDispatcher) { - pubkyService.initialize() - - val savedSecret = runCatching { - keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - }.getOrNull() - - if (savedSecret.isNullOrEmpty()) { - return@withContext InitResult.NoSession + suspend fun initialize() { + initializeMutex.withLock { + _sessionRestorationFailed.update { false } + val result = runCatching { + withContext(ioDispatcher) { + pubkyService.initialize() + + val savedSessionSecret = runCatching { + keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + }.getOrNull() + val storedSecretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.getOrNull() + + resolveSessionInitialization( + savedSessionSecret = savedSessionSecret, + storedSecretKeyHex = storedSecretKeyHex, + ) } + }.onFailure { + Logger.error("Failed to initialize paykit", it, context = TAG) + }.getOrNull() ?: return - 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("Paykit session restored 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 resolveSessionInitialization( + savedSessionSecret: String?, + storedSecretKeyHex: String?, + ): InitResult { + if (!savedSessionSecret.isNullOrEmpty()) { + return runCatching { + val publicKey = pubkyService.importSession(savedSessionSecret) + InitResult.Restored(publicKey) + }.getOrElse { + Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) + resolveSignedInSession(savedSessionSecret, storedSecretKeyHex) } } + + return resolveSignedInSession(savedSessionSecret, storedSecretKeyHex) } - private suspend fun tryReSignIn(): InitResult { - val secretKeyHex = runCatching { - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - }.getOrNull() + private suspend fun resolveSignedInSession( + savedSessionSecret: String?, + storedSecretKeyHex: String?, + ): InitResult { + if (storedSecretKeyHex.isNullOrEmpty()) { + if (!savedSessionSecret.isNullOrEmpty()) { + Logger.warn("Skipped re-sign-in recovery, no secret key available", context = TAG) + return InitResult.RestorationFailed + } - if (secretKeyHex.isNullOrEmpty()) { - Logger.warn("Skipped re-sign-in recovery, no secret key available", context = TAG) - return InitResult.RestorationFailed + return InitResult.NoSession } 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) + val newSession = pubkyService.signIn(storedSecretKeyHex) + keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) + notifyBackupStateChanged() + val publicKey = pubkyService.importSession(newSession) + Logger.info("Re-signed in and restored session for '$publicKey'", context = TAG) + InitResult.Restored(publicKey) }.getOrElse { - Logger.error("Re-sign-in recovery failed", it, context = TAG) + Logger.error("Failed re-sign-in recovery", it, context = TAG) runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + notifyBackupStateChanged() InitResult.RestorationFailed } } @@ -205,9 +236,9 @@ class PubkyRepo @Inject constructor( val sessionSecret = pubkyService.completeAuth() val pk = pubkyService.importSession(sessionSecret) - 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 } @@ -289,12 +320,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) @@ -325,6 +351,7 @@ class PubkyRepo @Inject constructor( 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() } @@ -401,7 +428,14 @@ class PubkyRepo @Inject constructor( "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() } @@ -690,6 +724,72 @@ 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) { + pubkyService.forceSignOut() + clearAuthenticatedState() + runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + + when (backup?.kind) { + null -> Unit + 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,9 +852,20 @@ 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 { + 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) + return pubkyService.deriveSecretKey(seed) + } + + private fun notifyBackupStateChanged() { + _backupStateVersion.update { it + 1 } + } + + private suspend fun clearAuthenticatedState() { evictPubkyImages() runCatching { withContext(ioDispatcher) { pubkyStore.reset() } } _publicKey.update { null } @@ -765,6 +876,13 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Idle } } + 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) } } + 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..1fa22df7b 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 @@ -1060,12 +1060,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) } }, ) } 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..aa06042cf 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, @@ -115,7 +123,7 @@ 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 @@ -139,17 +147,33 @@ 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) + modifier = Modifier.size(16.dp), ) + IconButton(onClick = { onRemoveLink(index) }) { + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp) + ) + } } }, modifier = Modifier .fillMaxWidth() + .border( + width = 1.dp, + color = Colors.White10, + shape = AppShapes.small, + ) .testTag("ProfileEditLink_$index"), ) VerticalSpacer(8.dp) @@ -157,7 +181,11 @@ fun ProfileEditForm( 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 = { @@ -195,7 +223,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 = { @@ -211,6 +243,8 @@ fun ProfileEditForm( VerticalSpacer(16.dp) if (showFooterNote) { + HorizontalDivider(color = Colors.White10) + VerticalSpacer(16.dp) BodyS( text = resolvedFooterNote, color = Colors.White64, 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..8f88016fc 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 @@ -86,15 +86,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 +104,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 +112,11 @@ fun AddContactSheet( } }, onScanQr = onScanQr, - onSubmit = { normalizedInput?.let(onSubmit) }, + onSubmit = { + val normalizedKey = (validationResult as? AddContactValidationResult.Valid)?.normalizedKey + ?: return@AddContactSheetContent + onSubmit(normalizedKey) + }, ) } } 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..2fb326e7f 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,7 +125,7 @@ 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), + text = stringResource(R.string.contacts__delete_confirm_text, currentProfile.name), confirmText = stringResource(R.string.contacts__delete_contact), onConfirm = onConfirmDelete, onDismiss = onDismissDeleteDialog, 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..3dd61897c 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,57 @@ 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 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) +} + 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/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt index cd0578791..55afc1e6e 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 @@ -35,6 +35,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 @@ -258,13 +259,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, @@ -277,16 +277,27 @@ private fun EmptyState( ) 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() } } 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/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt index afb9d78a7..d0c3e27f2 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, @@ -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..4832ef8d9 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,81 @@ class EditProfileViewModel @Inject constructor( fun deleteProfile() { viewModelScope.launch { - _uiState.update { it.copy(showDeleteDialog = false, isSaving = true) } - pubkyRepo.deleteProfile() + attemptDeleteProfile(allowSessionRefresh = true) + } + } + + fun retryDeleteProfile() { + viewModelScope.launch { + attemptDeleteProfile(allowSessionRefresh = false) + } + } + + 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(allowSessionRefresh: Boolean) { + _uiState.update { + it.copy( + showDeleteDialog = false, + showDeleteFailureDialog = false, + isSaving = true, + ) + } + pubkyRepo.deleteProfile() + .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) + + if (allowSessionRefresh) { + val refreshedSession = pubkyRepo.refreshSessionIfPossible() + .onFailure { + Logger.error("Failed to refresh pubky session", it, context = TAG) + } + .getOrDefault(false) + + if (refreshedSession) { + attemptDeleteProfile(allowSessionRefresh = false) + return + } + } + + _uiState.update { + it.copy( + isSaving = false, + showDeleteFailureDialog = true, + ) + } + } + } } @Stable @@ -244,6 +298,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 +306,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..c6cf7dcef 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,7 +155,9 @@ private fun ProfileBody( Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .clickableAlpha(onClick = onClickCopy) ) { QrCodeImage( content = profile.publicKey, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8ae385658..64272754c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -91,7 +91,6 @@ 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 @@ -100,6 +99,7 @@ import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork @@ -126,6 +126,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet +import to.bitkit.ui.screens.contacts.resolvePastedPubkyRoute import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -1037,7 +1038,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 +1061,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 +1280,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 +1319,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 +2290,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, + ) } } 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..e24fa3900 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,13 +84,15 @@ 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 Unable to load contact. Contact + Short note about this contact. Contact updated Edit Contact + Please note contact information is stored in public files.\nChanges you make to a contact in Bitkit will not update their profile. You don\'t have any contacts yet. Import All %1$d friends @@ -510,16 +512,18 @@ Profile created successfully This will delete your current Pubky profile data. You can create a new profile for this pubky later. 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.\nChanges 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..334aafa45 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -22,6 +22,8 @@ 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 kotlin.test.assertEquals @@ -32,6 +34,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) @@ -107,7 +110,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(testPk, 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 @@ -224,6 +227,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `signOut should clear state and keychain`() = test { authenticateForTesting() + clearInvocations(pubkyStore) val result = sut.signOut() @@ -339,6 +343,120 @@ 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 session from local secret key when saved session is missing`() = test { + val secretKey = "local_secret" + val session = "new_session" + val publicKey = VALID_SELF_KEY + 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(publicKey)).thenReturn(ffiProfile) + + sut.initialize() + + assertEquals(publicKey, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, session) } + } + + @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 `loadContacts should populate contacts on success`() = test { authenticateForTesting() @@ -650,6 +768,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", diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 01f0af3cc..c80240131 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -14,6 +14,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 +25,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 @@ -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..fb2afe0b4 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -0,0 +1,95 @@ +package to.bitkit.ui.screens.contacts + +import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.Routes +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ContactImportFlowTest { + @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), + ) + } + + @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(), + ), + ) + } + + private companion object { + const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + const val OTHER_VALID_PUBLIC_KEY = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" + } +} 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..9a9ce2b0d 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,18 +1,24 @@ 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.times +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 kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class EditProfileViewModelTest : BaseUnitTest() { @@ -21,18 +27,94 @@ class EditProfileViewModelTest : BaseUnitTest() { @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 retry after refreshing session`() = test { + whenever(pubkyRepo.deleteProfile()) + .thenReturn(Result.failure(RuntimeException("expired session"))) + .thenReturn(Result.success(Unit)) + whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(true)) + + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.deleteProfile() + advanceUntilIdle() + + assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) + } + assertFalse(sut.uiState.value.showDeleteFailureDialog) + verify(pubkyRepo, times(2)).deleteProfile() + } + + @Test + fun `deleteProfile should show retry dialog when delete still fails`() = test { + whenever(pubkyRepo.deleteProfile()).thenReturn(Result.failure(RuntimeException("expired session"))) + whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(false)) + + 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.deleteProfile()).thenReturn(Result.failure(RuntimeException("expired session"))) + whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(false)) + 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.deleteProfile()).thenReturn(Result.failure(RuntimeException("expired session"))) + whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(false)) + + 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( From 77818be3f675b3c179e62fd167505e195b474939 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 07:53:53 -0500 Subject: [PATCH 02/20] docs: add pr number to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8632f2c..fa7d613c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Improve Pubky profile restore, contact editing, and contact routing flows +- Improve Pubky profile restore, contact editing, and contact routing flows #905 ## [2.2.0] - 2026-04-07 From 5514d9c81efdde5f5e33e6264aa3af40cfd4ed9d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 08:28:08 -0500 Subject: [PATCH 03/20] fix: avoid pubky backup throw --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 448c45f2b..a7217cc59 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -508,7 +508,7 @@ class BackupRepo @Inject constructor( private suspend fun getMetadataBackupDataBytes(): ByteArray { val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val cacheData = cacheStore.data.first() - val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrThrow() + val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) val payload = MetadataBackupV1( createdAt = currentTimeMillis(), From 48b29f60f856e73053a50c70a841220d6db4b87c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 10:06:43 -0500 Subject: [PATCH 04/20] Update app/src/main/res/values/strings.xml Co-authored-by: piotr-iohk <42900201+piotr-iohk@users.noreply.github.com> --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e24fa3900..710e5f1dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,7 +92,7 @@ Short note about this contact. Contact updated Edit Contact - Please note contact information is stored in public files.\nChanges you make to a contact in Bitkit will not update their profile. + 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 From efdbf061f1fec1ee2543374a23d3fa1a021463fa Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 10:06:50 -0500 Subject: [PATCH 05/20] Update app/src/main/res/values/strings.xml Co-authored-by: piotr-iohk <42900201+piotr-iohk@users.noreply.github.com> --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 710e5f1dc..8f8b2c931 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -523,7 +523,7 @@ DELETE YOUR NAME Edit Profile - Please note profile information is stored in public files.\nChanges you make in Bitkit will not update your pubky.app profile. + 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 From 2e2706baefa6e1548321802889ad59a625045165 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 10:06:58 -0500 Subject: [PATCH 06/20] Update app/src/main/res/values/strings.xml Co-authored-by: piotr-iohk <42900201+piotr-iohk@users.noreply.github.com> --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f8b2c931..c5a3d3a20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -510,7 +510,7 @@ 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? We couldn\'t delete your profile data. Retry, or disconnect this pubky from Bitkit. Unable to Delete Profile From a78783249d435efc54815facf8b93433b6b6e2f7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 10:15:45 -0500 Subject: [PATCH 07/20] fix: truncate pubky contact names --- app/src/main/java/to/bitkit/ui/components/Tag.kt | 5 ++++- .../screens/contacts/ContactImportSelectScreen.kt | 14 ++++++++++++-- .../bitkit/ui/screens/contacts/ContactsScreen.kt | 10 +++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) 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..faa30b892 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,7 +48,9 @@ fun TagButton( BodySSB( text = text, color = textColor, - modifier = Modifier.testTag("Tag-$text") + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("Tag-$text"), ) if (displayIconClose) { 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..2353e399d 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 @@ -172,8 +173,17 @@ private fun SelectableContactRow( verticalArrangement = Arrangement.spacedBy(2.dp), 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) 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 55afc1e6e..8c66985be 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 @@ -24,6 +24,7 @@ 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 @@ -209,14 +210,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, ) } } From 93e981cd4b7190a932aef72cbe85e1d5fcd684e6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 13:42:39 -0500 Subject: [PATCH 08/20] fix: address claude review nits --- .../java/to/bitkit/ui/components/ProfileEditForm.kt | 6 +++--- app/src/main/java/to/bitkit/ui/components/Tag.kt | 2 +- app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt | 4 ++-- .../ui/screens/contacts/ContactImportFlowTest.kt | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) 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 aa06042cf..ed9c55d84 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -155,7 +155,7 @@ fun ProfileEditForm( painter = painterResource(R.drawable.ic_pencil_simple), contentDescription = null, tint = Colors.White64, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.dp) ) IconButton(onClick = { onRemoveLink(index) }) { Icon( @@ -195,7 +195,7 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, - modifier = Modifier.testTag("ProfileEditAddLink"), + modifier = Modifier.testTag("ProfileEditAddLink") ) } @@ -237,7 +237,7 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, - modifier = Modifier.testTag("ProfileEditAddTag"), + modifier = Modifier.testTag("ProfileEditAddTag") ) } 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 faa30b892..47c1cfa21 100644 --- a/app/src/main/java/to/bitkit/ui/components/Tag.kt +++ b/app/src/main/java/to/bitkit/ui/components/Tag.kt @@ -50,7 +50,7 @@ fun TagButton( color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.testTag("Tag-$text"), + modifier = Modifier.testTag("Tag-$text") ) if (displayIconClose) { diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index c80240131..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 @@ -33,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() { @@ -62,7 +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 { migrationService.getRNRemoteBackupTimestamp() }.thenReturn(null) whenever(connectivityRepo.isOnline).thenReturn(isOnline) sut = WalletViewModel( 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 index fb2afe0b4..ab38e8492 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -7,6 +7,11 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class ContactImportFlowTest { + private companion object { + const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + const val OTHER_VALID_PUBLIC_KEY = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" + } + @Test fun `resolveAddContactValidation returns empty for blank input`() { assertEquals( @@ -87,9 +92,4 @@ class ContactImportFlowTest { ), ) } - - private companion object { - const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - const val OTHER_VALID_PUBLIC_KEY = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" - } } From d325bc36c00bdfe74ab73cb7e1d72bce705be285 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 14:00:34 -0500 Subject: [PATCH 09/20] fix: update changelog category --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7d613c5..1becb2589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added +### Changed - Improve Pubky profile restore, contact editing, and contact routing flows #905 ## [2.2.0] - 2026-04-07 From 3dbc2993c1dee1b2b4931e12baf68dd4f150df30 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 14:34:12 -0500 Subject: [PATCH 10/20] fix: address claude pr feedback --- .../java/to/bitkit/repositories/BackupRepo.kt | 4 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 54 +++++++++------- .../ui/screens/contacts/ContactImportFlow.kt | 18 ------ .../screens/profile/EditProfileViewModel.kt | 22 ++----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 - .../bitkit/viewmodels/PubkyRouteResolver.kt | 23 +++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 38 ++++++++++++ .../screens/contacts/ContactImportFlowTest.kt | 51 ---------------- .../profile/EditProfileViewModelTest.kt | 42 +++++++++---- .../viewmodels/PubkyRouteResolverTest.kt | 61 +++++++++++++++++++ 10 files changed, 189 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt create mode 100644 app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a7217cc59..b99a47847 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -505,7 +505,7 @@ class BackupRepo @Inject constructor( BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } - private suspend fun getMetadataBackupDataBytes(): ByteArray { + private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val cacheData = cacheStore.data.first() val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) @@ -517,7 +517,7 @@ class BackupRepo @Inject constructor( pubkySession = pubkySession, ) - return json.encodeToString(payload).toByteArray() + json.encodeToString(payload).toByteArray() } suspend fun performFullRestoreFromLatestBackup( diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3f9a4f58d..bbd4d4ab6 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -171,45 +171,45 @@ class PubkyRepo @Inject constructor( private suspend fun resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, - ): InitResult { + ): InitResult = withContext(ioDispatcher) { if (!savedSessionSecret.isNullOrEmpty()) { - return runCatching { + runCatching { val publicKey = pubkyService.importSession(savedSessionSecret) 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 resolveSignedInSession(savedSessionSecret, storedSecretKeyHex) } private suspend fun resolveSignedInSession( savedSessionSecret: String?, storedSecretKeyHex: String?, - ): InitResult { + ): InitResult = withContext(ioDispatcher) { if (storedSecretKeyHex.isNullOrEmpty()) { if (!savedSessionSecret.isNullOrEmpty()) { Logger.warn("Skipped re-sign-in recovery, no secret key available", context = TAG) - return InitResult.RestorationFailed + 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) + 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 } - - return InitResult.NoSession - } - - return runCatching { - val newSession = pubkyService.signIn(storedSecretKeyHex) - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) - notifyBackupStateChanged() - val publicKey = pubkyService.importSession(newSession) - 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 } } @@ -422,6 +422,16 @@ 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)) { 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 3dd61897c..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 @@ -36,24 +36,6 @@ internal fun resolveAddContactValidation( return AddContactValidationResult.Valid(normalizedKey = normalizedKey) } -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) -} - internal fun shouldDiscardPendingImport(currentDestination: NavDestination?, destination: Routes?): Boolean { if (!currentDestination.isContactImportRoute()) { return false 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 4832ef8d9..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,13 +208,13 @@ class EditProfileViewModel @Inject constructor( fun deleteProfile() { viewModelScope.launch { - attemptDeleteProfile(allowSessionRefresh = true) + attemptDeleteProfile() } } fun retryDeleteProfile() { viewModelScope.launch { - attemptDeleteProfile(allowSessionRefresh = false) + attemptDeleteProfile() } } @@ -242,7 +242,7 @@ class EditProfileViewModel @Inject constructor( } } - private suspend fun attemptDeleteProfile(allowSessionRefresh: Boolean) { + private suspend fun attemptDeleteProfile() { _uiState.update { it.copy( showDeleteDialog = false, @@ -250,7 +250,7 @@ class EditProfileViewModel @Inject constructor( isSaving = true, ) } - pubkyRepo.deleteProfile() + pubkyRepo.deleteProfileWithSessionRetry() .onSuccess { _uiState.update { it.copy(isSaving = false) } ToastEventBus.send( @@ -261,20 +261,6 @@ class EditProfileViewModel @Inject constructor( } .onFailure { Logger.error("Failed to delete profile", it, context = TAG) - - if (allowSessionRefresh) { - val refreshedSession = pubkyRepo.refreshSessionIfPossible() - .onFailure { - Logger.error("Failed to refresh pubky session", it, context = TAG) - } - .getOrDefault(false) - - if (refreshedSession) { - attemptDeleteProfile(allowSessionRefresh = false) - return - } - } - _uiState.update { it.copy( isSaving = false, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 64272754c..c15cfea0e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -126,7 +126,6 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet -import to.bitkit.ui.screens.contacts.resolvePastedPubkyRoute import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute diff --git a/app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt b/app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt new file mode 100644 index 000000000..f3f84415f --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt @@ -0,0 +1,23 @@ +package to.bitkit.viewmodels + +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.ui.Routes + +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/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 334aafa45..3c70a246f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -274,6 +274,44 @@ class PubkyRepoTest : BaseUnitTest() { 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)).thenThrow(RuntimeException("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)).thenThrow(RuntimeException("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() 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 index ab38e8492..da4b5c398 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -1,15 +1,11 @@ package to.bitkit.ui.screens.contacts import org.junit.Test -import to.bitkit.models.PubkyProfile -import to.bitkit.ui.Routes import kotlin.test.assertEquals -import kotlin.test.assertNull class ContactImportFlowTest { private companion object { const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - const val OTHER_VALID_PUBLIC_KEY = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" } @Test @@ -45,51 +41,4 @@ class ContactImportFlowTest { resolveAddContactValidation(input = rawKey, ownPublicKey = null), ) } - - @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(), - ), - ) - } } 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 9a9ce2b0d..c2031e191 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 @@ -9,7 +9,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile @@ -36,11 +35,8 @@ class EditProfileViewModelTest : BaseUnitTest() { } @Test - fun `deleteProfile should retry after refreshing session`() = test { - whenever(pubkyRepo.deleteProfile()) - .thenReturn(Result.failure(RuntimeException("expired session"))) - .thenReturn(Result.success(Unit)) - whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(true)) + fun `deleteProfile should emit success when repository delete succeeds`() = test { + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit)) val sut = createSut() advanceUntilIdle() @@ -52,13 +48,31 @@ class EditProfileViewModelTest : BaseUnitTest() { assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) } assertFalse(sut.uiState.value.showDeleteFailureDialog) - verify(pubkyRepo, times(2)).deleteProfile() + 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.deleteProfile()).thenReturn(Result.failure(RuntimeException("expired session"))) - whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(false)) + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( + Result.failure(RuntimeException("expired session")), + ) val sut = createSut() advanceUntilIdle() @@ -72,8 +86,9 @@ class EditProfileViewModelTest : BaseUnitTest() { @Test fun `disconnectProfile should emit disconnect success`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.failure(RuntimeException("expired session"))) - whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(false)) + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( + Result.failure(RuntimeException("expired session")), + ) whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) val sut = createSut() @@ -93,8 +108,9 @@ class EditProfileViewModelTest : BaseUnitTest() { @Test fun `dismissDeleteFailureDialog should hide retry dialog`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.failure(RuntimeException("expired session"))) - whenever(pubkyRepo.refreshSessionIfPossible()).thenReturn(Result.success(false)) + whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( + Result.failure(RuntimeException("expired session")), + ) val sut = createSut() advanceUntilIdle() 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(), + ), + ) + } +} From 9bdfc4f8aca6ba316f71820f1da0a1001275794d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 14:58:43 -0500 Subject: [PATCH 11/20] fix: clean up modifier call sites --- .../ui/screens/contacts/ContactDetailScreen.kt | 18 +++++++++--------- .../contacts/ContactImportSelectScreen.kt | 6 +++--- .../ui/screens/contacts/ContactsScreen.kt | 12 ++++++------ .../bitkit/ui/screens/profile/ProfileScreen.kt | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) 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 2fb326e7f..7f92fd48a 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 @@ -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/ContactImportSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt index 2353e399d..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 @@ -171,7 +171,7 @@ private fun SelectableContactRow( Column( verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { BodyS( text = contact.profile.truncatedPublicKey, @@ -193,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) ) } } @@ -228,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 8c66985be..ed7578fe2 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 @@ -108,7 +108,7 @@ private fun Content( ActionButton( onClick = { showAddContactSheet = true }, iconRes = R.drawable.ic_plus, - modifier = Modifier.testTag("ContactsAddButton"), + modifier = Modifier.testTag("ContactsAddButton") ) } VerticalSpacer(8.dp) @@ -165,8 +165,8 @@ private fun ContactsList( ) ContactRow( profile = myProfile, - modifier = Modifier.testTag("ContactsMyProfile"), onClick = onClickMyProfile, + modifier = Modifier.testTag("ContactsMyProfile") ) HorizontalDivider() } @@ -185,8 +185,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() } @@ -212,7 +212,7 @@ private fun ContactRow( Column( verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { BodyS( text = profile.truncatedPublicKey, @@ -280,8 +280,8 @@ private fun EmptyState( ) ContactRow( profile = it, - modifier = Modifier.testTag("ContactsMyProfile"), onClick = onClickMyProfile, + modifier = Modifier.testTag("ContactsMyProfile") ) HorizontalDivider() } @@ -297,7 +297,7 @@ private fun EmptyState( PrimaryButton( text = stringResource(R.string.contacts__intro_add_contact), onClick = onAddContact, - modifier = Modifier.testTag("ContactsEmptyAddButton"), + modifier = Modifier.testTag("ContactsEmptyAddButton") ) BodyM( text = stringResource(R.string.contacts__empty_state), 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 c6cf7dcef..ddfe1136b 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 @@ -161,8 +161,8 @@ private fun ProfileBody( ) { QrCodeImage( content = profile.publicKey, - modifier = Modifier.fillMaxWidth(), testTag = "QRCode", + modifier = Modifier.fillMaxWidth() ) if (profile.imageUrl != null) { Box( @@ -263,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) } From daf65155a8ab03fd2691a149b4ed012effb3eee7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 23 Apr 2026 15:29:40 -0500 Subject: [PATCH 12/20] fix: wrap pubky init in io context --- .../java/to/bitkit/repositories/PubkyRepo.kt | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index bbd4d4ab6..75f94560f 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -125,28 +125,26 @@ class PubkyRepo @Inject constructor( // region Initialization - suspend fun initialize() { + suspend fun initialize() = withContext(ioDispatcher) { initializeMutex.withLock { _sessionRestorationFailed.update { false } val result = runCatching { - withContext(ioDispatcher) { - pubkyService.initialize() - - val savedSessionSecret = runCatching { - keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - }.getOrNull() - val storedSecretKeyHex = runCatching { - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - }.getOrNull() - - resolveSessionInitialization( - savedSessionSecret = savedSessionSecret, - storedSecretKeyHex = storedSecretKeyHex, - ) - } + pubkyService.initialize() + + val savedSessionSecret = runCatching { + keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + }.getOrNull() + val storedSecretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.getOrNull() + + resolveSessionInitialization( + savedSessionSecret = savedSessionSecret, + storedSecretKeyHex = storedSecretKeyHex, + ) }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) - }.getOrNull() ?: return + }.getOrNull() ?: return@withLock when (result) { is InitResult.NoSession -> { @@ -708,7 +706,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun clearPendingImport() { + suspend fun clearPendingImport() = withContext(ioDispatcher) { _pendingImportProfile.update { null } _pendingImportContacts.update { emptyList() } } @@ -862,22 +860,22 @@ class PubkyRepo @Inject constructor( null } - private suspend fun deriveLocalSecretKeyFromWalletSeed(): String { + 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) - return pubkyService.deriveSecretKey(seed) + pubkyService.deriveSecretKey(seed) } private fun notifyBackupStateChanged() { _backupStateVersion.update { it + 1 } } - private suspend fun clearAuthenticatedState() { + private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { evictPubkyImages() - runCatching { withContext(ioDispatcher) { pubkyStore.reset() } } + runCatching { pubkyStore.reset() } _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } @@ -886,9 +884,9 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Idle } } - 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 clearLocalState() = withContext(ioDispatcher) { + runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } notifyBackupStateChanged() clearAuthenticatedState() } From 675769b696b5ffde59bb40df9dc2a7903f6b696c Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 24 Apr 2026 10:20:18 +0200 Subject: [PATCH 13/20] fix: remove redundant space in desc --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5a3d3a20..0e81be2d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -510,7 +510,7 @@ Create Profile Restoring your existing profile… Profile created successfully - Are you sure you want to delete all of your profile information for your pubky? + Are you sure you want to delete all of your profile information for your pubky? Delete Profile? We couldn\'t delete your profile data. Retry, or disconnect this pubky from Bitkit. Unable to Delete Profile From 89aa453abbe08f08c1deff0709c7285ce9ca0a08 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 24 Apr 2026 11:08:14 +0200 Subject: [PATCH 14/20] test: e2e tags for add contact, profile qr, and profile edit Made-with: Cursor --- .../bitkit/ui/components/ProfileEditForm.kt | 10 ++++--- .../ui/screens/contacts/AddContactScreen.kt | 27 ++++++++++++++----- .../screens/contacts/AddContactViewModel.kt | 1 + .../ui/screens/profile/ProfileScreen.kt | 4 +-- 4 files changed, 31 insertions(+), 11 deletions(-) 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 ed9c55d84..6a5712404 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -157,12 +157,15 @@ fun ProfileEditForm( tint = Colors.White64, modifier = Modifier.size(16.dp) ) - IconButton(onClick = { onRemoveLink(index) }) { + 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.size(16.dp), ) } } @@ -252,7 +255,7 @@ fun ProfileEditForm( } if (onDelete != null) { - Column(modifier = Modifier.testTag("ProfileEditDelete")) { + Column { VerticalSpacer(16.dp) HorizontalDivider() VerticalSpacer(16.dp) @@ -276,6 +279,7 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, + modifier = Modifier.testTag("ProfileEditDelete"), ) } } 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 8f88016fc..830c6ccad 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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -160,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, @@ -168,7 +172,9 @@ private fun AddContactSheetContent( ) } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag("AddContactPubkyField"), ) VerticalSpacer(16.dp) @@ -179,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) @@ -408,6 +418,7 @@ private fun ErrorContent( SecondaryButton( text = stringResource(R.string.common__retry), onClick = onRetry, + modifier = Modifier.testTag("AddContactRetry"), ) } } @@ -449,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/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index ddfe1136b..0702311da 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 @@ -161,8 +161,8 @@ private fun ProfileBody( ) { QrCodeImage( content = profile.publicKey, - testTag = "QRCode", - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + testTag = "ProfileQRCode", ) if (profile.imageUrl != null) { Box( From 733dec3862c59f5d21fc5eb724fdfe2d0a811c9c Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 24 Apr 2026 11:08:17 +0200 Subject: [PATCH 15/20] fix: contact delete toast, ContactDeletedToast, and yes delete on detail Made-with: Cursor --- .../to/bitkit/ui/screens/contacts/ContactDetailScreen.kt | 2 +- .../to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt | 5 +++++ .../to/bitkit/ui/screens/contacts/EditContactViewModel.kt | 5 +++++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 12 insertions(+), 1 deletion(-) 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 7f92fd48a..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 @@ -126,7 +126,7 @@ private fun Content( AppAlertDialog( title = stringResource(R.string.contacts__delete_confirm_title, currentProfile.name), text = stringResource(R.string.contacts__delete_confirm_text, currentProfile.name), - confirmText = stringResource(R.string.contacts__delete_contact), + confirmText = stringResource(R.string.common__delete_yes), onConfirm = onConfirmDelete, onDismiss = onDismissDeleteDialog, ) 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/EditContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt index 140fac204..cfd59ab36 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 @@ -213,6 +213,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e81be2d0..f00160f9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ 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. From 78aeb7c7e6d48e39584c4be4db3e4337c1a221ee Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 24 Apr 2026 08:35:17 -0500 Subject: [PATCH 16/20] fix: polish contact add flow --- app/src/main/java/to/bitkit/ui/ContentView.kt | 22 ++++++++++++------- .../ui/components/CenteredProfileHeader.kt | 4 ++++ .../to/bitkit/ui/components/DrawerMenu.kt | 7 +++--- .../bitkit/ui/components/ProfileEditForm.kt | 10 ++++----- .../main/java/to/bitkit/ui/components/Text.kt | 4 ++++ .../ui/screens/contacts/AddContactScreen.kt | 12 +++++----- .../ui/screens/contacts/ContactsScreen.kt | 8 +++++-- .../ui/screens/profile/EditProfileScreen.kt | 2 +- .../ui/screens/profile/ProfileScreen.kt | 2 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 2 ++ .../profile/EditProfileViewModelTest.kt | 17 ++++++++------ 11 files changed, 57 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 1fa22df7b..bae2bab20 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -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) } }, ) } @@ -1894,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..e3fb4b2ac 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 @@ -67,6 +68,9 @@ fun CenteredProfileHeader( Display( text = name, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, modifier = if (nameTestTag != null) Modifier.testTag(nameTestTag) 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 6a5712404..d890e4adb 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -96,7 +96,7 @@ fun ProfileEditForm( colors = AppTextFieldDefaults.transparent, modifier = Modifier .fillMaxWidth() - .testTag("ProfileEditName"), + .testTag("ProfileEditName") ) HorizontalDivider() VerticalSpacer(12.dp) @@ -128,7 +128,7 @@ fun ProfileEditForm( maxLines = 4, modifier = Modifier .fillMaxWidth() - .testTag("ProfileEditBio"), + .testTag("ProfileEditBio") ) VerticalSpacer(16.dp) @@ -177,7 +177,7 @@ fun ProfileEditForm( color = Colors.White10, shape = AppShapes.small, ) - .testTag("ProfileEditLink_$index"), + .testTag("ProfileEditLink_$index") ) VerticalSpacer(8.dp) } @@ -296,7 +296,7 @@ fun ProfileEditForm( onClick = onCancel, modifier = Modifier .weight(1f) - .testTag("ProfileEditCancel"), + .testTag("ProfileEditCancel") ) PrimaryButton( text = stringResource(R.string.common__save), @@ -304,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/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index ee98a2ce7..f8a452373 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,6 +35,8 @@ fun Display( fontSize = fontSize, color = color, ), + maxLines = maxLines, + overflow = overflow, textAlign = textAlign, 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 830c6ccad..4acf48bf4 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 @@ -271,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) @@ -287,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(), @@ -306,7 +306,7 @@ private fun LoadingContent(publicKey: String) { contentAlignment = Alignment.Center, modifier = Modifier .weight(1f) - .fillMaxWidth(), + .fillMaxWidth() ) { RotatingEllipses(modifier = Modifier.size(256.dp)) } @@ -391,7 +391,7 @@ private fun RotatingEllipses(modifier: Modifier = Modifier) { style = dashedStroke, ) } - }, + } ) { Image( painter = painterResource(R.drawable.card), @@ -411,7 +411,7 @@ 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) @@ -434,7 +434,7 @@ private fun LoadedContent( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp), + .padding(horizontal = 32.dp) ) { VerticalSpacer(24.dp) 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 ed7578fe2..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,7 +17,7 @@ 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 @@ -59,6 +59,7 @@ fun ContactsScreen( onClickContact: (String) -> Unit, onAddContact: (String) -> Unit = {}, onScanQr: () -> Unit = {}, + openAddContactSheet: Boolean = false, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -72,6 +73,7 @@ fun ContactsScreen( onSearchTextChange = { viewModel.onSearchTextChange(it) }, onAddContact = onAddContact, onScanQr = onScanQr, + openAddContactSheet = openAddContactSheet, ) } @@ -84,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( @@ -329,6 +332,7 @@ private fun Preview() { onSearchTextChange = {}, onAddContact = {}, onScanQr = {}, + openAddContactSheet = false, ) } } 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 d0c3e27f2..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 @@ -224,7 +224,7 @@ private fun AvatarSection( .clip(CircleShape) .background(Colors.Gray5) .testTag("EditProfileAvatar") - .clickable(onClick = onClick), + .clickable(onClick = onClick) ) { when { newAvatarUri != null -> AsyncImage( 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 0702311da..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 @@ -217,7 +217,7 @@ private fun ProfileBody( color = Colors.White64, modifier = Modifier .fillMaxWidth() - .testTag("ProfileViewTagsHeader"), + .testTag("ProfileViewTagsHeader") ) VerticalSpacer(8.dp) @OptIn(ExperimentalLayoutApi::class) 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/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index c2031e191..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 @@ -15,12 +15,17 @@ 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() @@ -71,7 +76,7 @@ class EditProfileViewModelTest : BaseUnitTest() { @Test fun `deleteProfile should show retry dialog when delete still fails`() = test { whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( - Result.failure(RuntimeException("expired session")), + Result.failure(TestAppError("expired session")), ) val sut = createSut() @@ -87,7 +92,7 @@ class EditProfileViewModelTest : BaseUnitTest() { @Test fun `disconnectProfile should emit disconnect success`() = test { whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( - Result.failure(RuntimeException("expired session")), + Result.failure(TestAppError("expired session")), ) whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) @@ -109,7 +114,7 @@ class EditProfileViewModelTest : BaseUnitTest() { @Test fun `dismissDeleteFailureDialog should hide retry dialog`() = test { whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn( - Result.failure(RuntimeException("expired session")), + Result.failure(TestAppError("expired session")), ) val sut = createSut() @@ -142,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) From 51d1e6a8092717ca754a1e47f28745dd03c82a15 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 24 Apr 2026 16:35:38 +0200 Subject: [PATCH 17/20] test: ContactUpdatedToast --- .../java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 cfd59ab36..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 { From 4fabedecaef78cb68eebcffacc07d1fbd88522ea Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 24 Apr 2026 09:35:39 -0500 Subject: [PATCH 18/20] fix: normalize pubky session keys --- .../java/to/bitkit/repositories/PubkyRepo.kt | 16 +++--- .../ui/screens/contacts/AddContactScreen.kt | 2 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 50 +++++++++++++------ 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 75f94560f..81f46c2fc 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -154,7 +154,7 @@ class PubkyRepo @Inject constructor( is InitResult.Restored -> { _publicKey.update { result.publicKey } _authState.update { PubkyAuthState.Authenticated } - Logger.info("Paykit session restored for '${result.publicKey}'", context = TAG) + Logger.info("Restored paykit session for '${result.publicKey}'", context = TAG) loadProfile() loadContacts() } @@ -172,7 +172,7 @@ class PubkyRepo @Inject constructor( ): InitResult = withContext(ioDispatcher) { if (!savedSessionSecret.isNullOrEmpty()) { runCatching { - val publicKey = pubkyService.importSession(savedSessionSecret) + val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() InitResult.Restored(publicKey) }.getOrElse { Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) @@ -199,7 +199,7 @@ class PubkyRepo @Inject constructor( val newSession = pubkyService.signIn(storedSecretKeyHex) keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) notifyBackupStateChanged() - val publicKey = pubkyService.importSession(newSession) + val publicKey = pubkyService.importSession(newSession).ensurePubkyPrefix() Logger.info("Re-signed in and restored session for '$publicKey'", context = TAG) InitResult.Restored(publicKey) }.getOrElse { @@ -232,7 +232,7 @@ 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.PUBKY_SECRET_KEY.name) } keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) @@ -245,7 +245,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 { } } @@ -253,7 +253,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 } } @@ -343,7 +343,7 @@ 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) } @@ -357,7 +357,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() } 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 4acf48bf4..771a8f016 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 @@ -30,7 +30,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -41,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 diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 3c70a246f..913b8b5a2 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -97,18 +97,18 @@ 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) { upsertString(Keychain.Key.PAYKIT_SESSION.name, testSecret) } } @@ -116,11 +116,11 @@ class PubkyRepoTest : BaseUnitTest() { @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() @@ -131,11 +131,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() @@ -420,21 +420,37 @@ class PubkyRepoTest : BaseUnitTest() { 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 + 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(publicKey)).thenReturn(ffiProfile) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) sut.initialize() - assertEquals(publicKey, sut.publicKey.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertTrue(sut.isAuthenticated.value) verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, session) } } @@ -559,14 +575,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) } @@ -599,7 +615,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 { @@ -610,7 +626,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) @@ -855,10 +871,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()) @@ -874,3 +891,6 @@ class PubkyRepoTest : BaseUnitTest() { return ffiProfile } } + +private fun String.ensurePubkyPrefixForTest(): String = + if (startsWith("pubky")) this else "pubky$this" From 4033b1edfad0fc366d7924a60fef2b99ceade8b0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 24 Apr 2026 10:17:40 -0500 Subject: [PATCH 19/20] fix: harden pubky restore --- .../java/to/bitkit/repositories/PubkyRepo.kt | 40 ++++++----- .../ui/screens/contacts/AddContactScreen.kt | 14 ++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 66 +++++++++++++++---- 3 files changed, 84 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 81f46c2fc..5ba5f3622 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -190,6 +190,8 @@ class PubkyRepo @Inject constructor( 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 @@ -755,27 +757,33 @@ class PubkyRepo @Inject constructor( suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runCatching { withContext(ioDispatcher) { - pubkyService.forceSignOut() - clearAuthenticatedState() - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - - when (backup?.kind) { - null -> Unit - PubkySessionBackupKind.LocalSeed -> { - val secretKeyHex = deriveLocalSecretKeyFromWalletSeed() - keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) + initializeMutex.withLock { + if (backup == null) { + notifyBackupStateChanged() + return@withLock } - PubkySessionBackupKind.ExternalSession -> { - val sessionSecret = requireNotNull(backup.sessionSecret?.takeIf { it.isNotBlank() }) { - "Missing session secret in backup" + 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) } - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) } - } - notifyBackupStateChanged() + notifyBackupStateChanged() + } } } 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 771a8f016..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 @@ -163,7 +163,7 @@ private fun AddContactSheetContent( trailingIcon = { IconButton( onClick = onPaste, - modifier = Modifier.testTag("AddContactPaste"), + modifier = Modifier.testTag("AddContactPaste") ) { Icon( painter = painterResource(R.drawable.ic_clipboard_text), @@ -174,7 +174,7 @@ private fun AddContactSheetContent( }, modifier = Modifier .fillMaxWidth() - .testTag("AddContactPubkyField"), + .testTag("AddContactPubkyField") ) VerticalSpacer(16.dp) @@ -187,7 +187,7 @@ private fun AddContactSheetContent( onClick = onScanQr, modifier = Modifier .weight(1f) - .testTag("AddContactScanQR"), + .testTag("AddContactScanQR") ) PrimaryButton( text = stringResource(R.string.contacts__add_button), @@ -195,7 +195,7 @@ private fun AddContactSheetContent( enabled = isSubmitEnabled, modifier = Modifier .weight(1f) - .testTag("AddContactAdd"), + .testTag("AddContactAdd") ) } VerticalSpacer(16.dp) @@ -418,7 +418,7 @@ private fun ErrorContent( SecondaryButton( text = stringResource(R.string.common__retry), onClick = onRetry, - modifier = Modifier.testTag("AddContactRetry"), + modifier = Modifier.testTag("AddContactRetry") ) } } @@ -462,7 +462,7 @@ private fun LoadedContent( onClick = onDiscard, modifier = Modifier .weight(1f) - .testTag("AddContactDiscard"), + .testTag("AddContactDiscard") ) PrimaryButton( text = stringResource(R.string.common__save), @@ -470,7 +470,7 @@ private fun LoadedContent( enabled = !isLoading, modifier = Modifier .weight(1f) - .testTag("AddContactSave"), + .testTag("AddContactSave") ) } VerticalSpacer(16.dp) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 913b8b5a2..67acbd04f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -26,6 +26,7 @@ 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 @@ -84,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() @@ -145,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() @@ -193,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() @@ -266,8 +267,8 @@ 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() @@ -284,7 +285,11 @@ class PubkyRepoTest : BaseUnitTest() { 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)).thenThrow(RuntimeException("Expired")) + whenever( + pubkyService.sessionDelete(expiredSession, Env.profilePath) + ).thenAnswer { + throw TestAppError("Expired") + } whenever(pubkyService.signIn(secretKey)).thenReturn(newSession) whenever(pubkyService.importSession(newSession)).thenReturn(VALID_SELF_KEY) @@ -303,7 +308,11 @@ class PubkyRepoTest : BaseUnitTest() { 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)).thenThrow(RuntimeException("Expired")) + whenever( + pubkyService.sessionDelete(expiredSession, Env.profilePath) + ).thenAnswer { + throw TestAppError("Expired") + } val result = sut.deleteProfileWithSessionRetry() @@ -315,7 +324,7 @@ class PubkyRepoTest : BaseUnitTest() { @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() @@ -455,6 +464,20 @@ class PubkyRepoTest : BaseUnitTest() { 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" @@ -511,6 +534,21 @@ class PubkyRepoTest : BaseUnitTest() { 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() @@ -651,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() @@ -666,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() @@ -695,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) @@ -711,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) @@ -892,5 +930,7 @@ class PubkyRepoTest : BaseUnitTest() { } } +private class TestAppError(message: String) : AppError(message) + private fun String.ensurePubkyPrefixForTest(): String = if (startsWith("pubky")) this else "pubky$this" From ebd1fe11f34bdb40fd7ac88436b360f3fc9b31db Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 24 Apr 2026 10:57:59 -0500 Subject: [PATCH 20/20] fix: address claude review --- .../java/to/bitkit/repositories/PubkyRepo.kt | 31 ++++++++++++++----- .../ui/components/CenteredProfileHeader.kt | 10 +++--- .../bitkit/ui/components/ProfileEditForm.kt | 6 ++-- .../main/java/to/bitkit/ui/components/Text.kt | 30 +++++++++--------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 ++++++++++++ .../bitkit/viewmodels/PubkyRouteResolver.kt | 23 -------------- 6 files changed, 67 insertions(+), 53 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 5ba5f3622..6cf2104c2 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -69,9 +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) @@ -126,11 +128,15 @@ class PubkyRepo @Inject constructor( // region Initialization suspend fun initialize() = withContext(ioDispatcher) { + runCatching { + ensureServiceInitialized() + }.onFailure { + Logger.error("Failed to initialize paykit", it, context = TAG) + }.getOrNull() ?: return@withContext + initializeMutex.withLock { _sessionRestorationFailed.update { false } val result = runCatching { - pubkyService.initialize() - val savedSessionSecret = runCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }.getOrNull() @@ -166,6 +172,15 @@ class PubkyRepo @Inject constructor( } } + private suspend fun ensureServiceInitialized() = withContext(ioDispatcher) { + serviceInitializeMutex.withLock { + if (!isServiceInitialized) { + pubkyService.initialize() + isServiceInitialized = true + } + } + } + private suspend fun resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, @@ -757,12 +772,14 @@ class PubkyRepo @Inject constructor( suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runCatching { withContext(ioDispatcher) { - initializeMutex.withLock { - if (backup == null) { - notifyBackupStateChanged() - return@withLock - } + if (backup == null) { + notifyBackupStateChanged() + return@withContext + } + ensureServiceInitialized() + + initializeMutex.withLock { pubkyService.forceSignOut() clearAuthenticatedState() runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } 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 e3fb4b2ac..93956881a 100644 --- a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt +++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt @@ -35,7 +35,7 @@ fun CenteredProfileHeader( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier, + modifier = modifier ) { Text13Up( text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH), @@ -53,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) ) } } @@ -71,7 +71,7 @@ fun CenteredProfileHeader( maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, - modifier = if (nameTestTag != null) Modifier.testTag(nameTestTag) else Modifier, + modifier = if (nameTestTag != null) Modifier.testTag(nameTestTag) else Modifier ) if (bio.isNotEmpty()) { @@ -80,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/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index d890e4adb..84f76d96f 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -159,13 +159,13 @@ fun ProfileEditForm( ) IconButton( onClick = { onRemoveLink(index) }, - modifier = Modifier.testTag("ProfileEditLinkRemove_$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.size(16.dp) ) } } @@ -279,7 +279,7 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) }, - modifier = Modifier.testTag("ProfileEditDelete"), + modifier = Modifier.testTag("ProfileEditDelete") ) } } 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 f8a452373..519baa5be 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -38,7 +38,7 @@ fun Display( maxLines = maxLines, overflow = overflow, textAlign = textAlign, - modifier = modifier, + modifier = modifier ) } @@ -53,7 +53,7 @@ fun Display( style = AppTextStyles.Display.merge( color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -68,7 +68,7 @@ fun Headline( style = AppTextStyles.Headline.merge( color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -86,7 +86,7 @@ fun Headline20( letterSpacing = (-.5).sp, color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -103,7 +103,7 @@ fun Headline24( lineHeight = 24.sp, color = color, ), - modifier = modifier, + modifier = modifier ) } @@ -124,7 +124,7 @@ fun Title( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -145,7 +145,7 @@ fun Subtitle( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -189,7 +189,7 @@ fun BodyM( maxLines = maxLines, minLines = minLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -229,7 +229,7 @@ fun BodyMSB( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -250,7 +250,7 @@ fun BodyMB( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -343,7 +343,7 @@ fun BodySB( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -360,7 +360,7 @@ fun Text13Up( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -381,7 +381,7 @@ fun Caption( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } @@ -438,7 +438,7 @@ fun Caption13Up( color = color, textAlign = textAlign, ), - modifier = modifier, + modifier = modifier ) } @@ -459,6 +459,6 @@ fun Footnote( ), maxLines = maxLines, overflow = overflow, - modifier = modifier, + modifier = modifier ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c15cfea0e..46d973105 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -95,6 +95,8 @@ 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 @@ -2724,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/PubkyRouteResolver.kt b/app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt deleted file mode 100644 index f3f84415f..000000000 --- a/app/src/main/java/to/bitkit/viewmodels/PubkyRouteResolver.kt +++ /dev/null @@ -1,23 +0,0 @@ -package to.bitkit.viewmodels - -import to.bitkit.models.PubkyProfile -import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.ui.Routes - -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) -}