From edd647611a1bd695f12a6f7eff69acdd19f03019 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 08:36:08 +0200 Subject: [PATCH 1/5] fix: bind profile removal to its original owner --- .../profile/ProfileRecoveryContentTest.kt | 126 +++++ .../to/bitkit/data/ProfileRemovalStore.kt | 200 ++++++++ .../java/to/bitkit/repositories/BackupRepo.kt | 21 +- .../ContactPaymentSettingsRepo.kt | 53 +- .../PrivatePaykitContactResolver.kt | 33 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 151 ++++-- .../repositories/PubkyIdentityOperations.kt | 126 +++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 323 +++++++++--- .../java/to/bitkit/services/CoreService.kt | 15 +- .../bitkit/services/PaykitSdkOperationLock.kt | 156 +++++- .../to/bitkit/services/PaykitSdkService.kt | 235 +++++++-- .../java/to/bitkit/services/PubkyService.kt | 30 ++ .../ui/screens/profile/EditProfileScreen.kt | 46 +- .../screens/profile/EditProfileViewModel.kt | 176 ++++--- .../ui/screens/profile/ProfileScreen.kt | 32 +- .../ui/screens/profile/ProfileViewModel.kt | 152 ++++-- .../usecases/ProfileRemovalCoordinator.kt | 169 +++++++ .../to/bitkit/usecases/WipeWalletUseCase.kt | 50 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 45 +- app/src/main/res/values-ar/strings.xml | 4 + app/src/main/res/values-b+es+419/strings.xml | 4 + app/src/main/res/values-ca/strings.xml | 4 + app/src/main/res/values-cs/strings.xml | 4 + app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-el/strings.xml | 4 + app/src/main/res/values-es-rES/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values-nl/strings.xml | 4 + app/src/main/res/values-pl/strings.xml | 4 + app/src/main/res/values-pt-rBR/strings.xml | 4 + app/src/main/res/values-pt/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../to/bitkit/data/ProfileRemovalStoreTest.kt | 203 ++++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 46 ++ .../ContactPaymentSettingsRepoTest.kt | 89 ++++ .../PrivatePaykitAllowancePaymentTest.kt | 3 + .../PrivatePaykitContactResolverTest.kt | 72 +++ .../repositories/PrivatePaykitRepoTest.kt | 211 +++++++- .../PubkyIdentityOperationsTest.kt | 142 ++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 469 +++++++++++++++-- .../ActivityServicePaykitContactsTest.kt | 27 + .../services/PaykitSdkOperationLockTest.kt | 181 +++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 443 ++++++++++++++++ .../contacts/ContactSaveSessionChangeTest.kt | 29 ++ .../profile/EditProfileViewModelTest.kt | 478 ++++++++---------- .../profile/ProfileRemovalUiFixture.kt | 60 +++ .../screens/profile/ProfileViewModelTest.kt | 306 +++++++---- .../usecases/ProfileRemovalCoordinatorTest.kt | 416 +++++++++++++++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 302 +++++++++-- .../viewmodels/AppViewModelSendFlowTest.kt | 72 ++- changelog.d/next/1687.fixed.md | 1 + journeys/profile/README.md | 36 +- .../delete-profile-uncertain-response.xml | 2 +- journeys/profile/delete-profile.xml | 3 +- journeys/profile/profile-cleanup-failure.xml | 7 +- journeys/profile/profile-removal-recovery.xml | 25 + 59 files changed, 5005 insertions(+), 821 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt create mode 100644 app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PubkyIdentityOperations.kt create mode 100644 app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt create mode 100644 app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PubkyIdentityOperationsTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/profile/ProfileRemovalUiFixture.kt create mode 100644 app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt create mode 100644 changelog.d/next/1687.fixed.md create mode 100644 journeys/profile/profile-removal-recovery.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt new file mode 100644 index 0000000000..7c67be5946 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt @@ -0,0 +1,126 @@ +package to.bitkit.ui.screens.profile + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import to.bitkit.R +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals + +@ComposeUi +class ProfileRecoveryContentTest { + @get:Rule + val compose = createComposeRule() + + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun restartRecoveryWithoutCredentialsRequiresLocalConfirmation() { + var state by mutableStateOf(ProfileUiState(recoveryPending = true, localDisconnect = true)) + var confirmations = 0 + compose.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + ProfileContent( + uiState = state, + onBackClick = {}, + onClickEdit = { error("Pending recovery must not offer editing") }, + onClickCopy = {}, + onClickShare = {}, + onClickSignOut = { state = state.copy(showSignOutDialog = true) }, + onDismissSignOutDialog = { state = state.copy(showSignOutDialog = false) }, + onConfirmSignOut = { + confirmations++ + state = state.copy(showSignOutDialog = false, isSigningOut = true) + }, + onClickRetry = { error("Pending recovery must not restore credentials") }, + onClickAddTag = {}, + onRemoveTag = {}, + onDismissAddTagSheet = {}, + onSaveTag = {}, + onDismissCopiedPopup = {}, + ) + } + } + } + compose.onNodeWithText(context.getString(R.string.profile__cleanup_pending)).assertIsDisplayed() + compose.onNodeWithTag("ProfileRecoveryDisconnect").assertIsDisplayed().performClick() + compose.onNodeWithText(context.getString(R.string.profile__disconnect_local_description)).assertIsDisplayed() + compose.runOnIdle { assertEquals(0, confirmations) } + compose.onNodeWithTag("DialogCancel").performClick() + val dismissedAt = System.currentTimeMillis() + compose.runOnIdle { assertEquals(0, confirmations) } + compose.waitUntil(1_000) { System.currentTimeMillis() - dismissedAt > 600 } + compose.onNodeWithTag("ProfileRecoveryDisconnect").performClick() + compose.onNodeWithTag("DialogConfirm").performClick() + compose.runOnIdle { assertEquals(1, confirmations) } + compose.onNodeWithTag("ProfileRecoveryDisconnect").assertIsNotEnabled() + } + + @Test + fun uncertainDeletionOffersReviewThenSeparateConfirmation() { + var state by mutableStateOf(EditProfileUiState(showDeleteFailureDialog = true, canReviewRemoval = true)) + var reviews = 0 + var deletes = 0 + editContent({ state }, onReview = { + reviews++ + state = state.copy(showDeleteFailureDialog = false, showDeleteDialog = true) + }, onConfirm = { deletes++ }) + compose.onNodeWithText(context.getString(R.string.profile__review_removal)).assertIsDisplayed() + compose.onNodeWithTag("DialogConfirm").performClick() + compose.runOnIdle { + assertEquals(1, reviews) + assertEquals(0, deletes) + } + compose.onNodeWithTag("DialogConfirm").performClick() + compose.runOnIdle { assertEquals(1, deletes) } + } + + @Test + fun localCompletionFailureOnlyOffersDisconnectAndClose() { + var disconnects = 0 + editContent( + state = { EditProfileUiState(showDeleteFailureDialog = true, canReviewRemoval = false) }, + onReview = { error("Local completion cannot repeat remote deletion") }, + onDisconnect = { disconnects++ }, + ) + compose.onNodeWithText(context.getString(R.string.profile__review_removal)).assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.common__close)).assertIsDisplayed() + compose.onNodeWithTag("DialogConfirm").performClick() + compose.runOnIdle { assertEquals(1, disconnects) } + } + + private fun editContent( + state: () -> EditProfileUiState, + onReview: () -> Unit, + onDisconnect: () -> Unit = {}, + onConfirm: () -> Unit = {}, + ) { + compose.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + EditProfileContent( + uiState = state(), + onBackClick = {}, onNameChange = {}, onBioChange = {}, onLinkUrlChange = { _, _ -> }, + onAvatarSelected = {}, onAddLink = {}, onRemoveLink = {}, onAddTag = {}, onRemoveTag = {}, + onSave = {}, onDelete = {}, onDismissDeleteDialog = {}, onConfirmDelete = onConfirm, + onRetryDelete = onReview, onDismissDeleteFailureDialog = {}, onDisconnectProfile = onDisconnect, + onDismissAddLinkSheet = {}, onSaveLink = { _, _ -> }, onDismissAddTagSheet = {}, onSaveTag = {}, + ) + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt new file mode 100644 index 0000000000..b0cc7033a7 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt @@ -0,0 +1,200 @@ +package to.bitkit.data + +import android.content.Context +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull +import to.bitkit.di.json +import to.bitkit.utils.AppError +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.CharacterCodingException +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.milliseconds + +private val Context.profileRemovalDataStore: DataStore by dataStore( + fileName = "profile_removal.json", + serializer = ProfileRemovalSerializer, + corruptionHandler = profileRemovalCorruptionHandler(), +) + +/** Local recovery admission only. A pending attempt is never evidence that a remote mutation succeeded. */ +@Singleton +class ProfileRemovalStore internal constructor(private val store: DataStore) { + internal companion object { + /** Used only by the existing service's injected-handle unit-test constructor. */ + fun inMemoryForTesting() = ProfileRemovalStore(object : DataStore { + private val state = MutableStateFlow(ProfileRemovalState()) + private val mutex = Mutex() + override val data: Flow = state + override suspend fun updateData(transform: suspend (ProfileRemovalState) -> ProfileRemovalState) = + mutex.withLock { transform(state.value).also { state.value = it } } + }) + } + + @Inject + constructor(@ApplicationContext context: Context) : this(context.profileRemovalDataStore) + + private val observationRevision = MutableStateFlow(0L) + internal val generation: Long get() = observationRevision.value + + @OptIn(ExperimentalCoroutinesApi::class) + internal val pending: Flow = observationRevision.flatMapLatest { + store.data.map { it.attemptId != null } + .retryWhen { _, attempt -> + emit(true) + if (attempt < 3) { + delay((250L shl attempt.toInt()).milliseconds) + true + } else { + false + } + } + .catch { emit(true) } + } + + internal suspend fun pendingAttempt(): String? = store.data.first().attemptId + + internal suspend fun requireNoPending() { + if (pendingAttempt() != null) throw ProfileRemovalPending() + } + + internal suspend fun requireAccess() { + val pending = pendingAttempt() ?: return + val access = currentCoroutineContext()[Access] + if (access?.store !== this || access.attemptId != pending || !access.active.get()) { + throw ProfileRemovalPending() + } + access.job.ensureActive() + } + + /** Only for local completion after acknowledged credential teardown, while outer removal ownership is retained. */ + internal suspend fun requireCompletionAccess() { + val pending = pendingAttempt() ?: return + val access = currentCoroutineContext()[Access] + if (access?.store !== this || access.attemptId != pending || !access.active.get()) { + throw ProfileRemovalPending() + } + } + + internal suspend fun withAccess(attemptId: String, operation: suspend () -> T): T { + require(UUID.fromString(attemptId).toString() == attemptId) { "Invalid profile removal attempt" } + val pending = pendingAttempt() + check(pending == null || pending == attemptId) { "Another profile removal needs local recovery" } + val access = Access(this, attemptId, requireNotNull(currentCoroutineContext()[Job])) + try { + return withContext(access) { operation() } + } finally { + access.active.set(false) + } + } + + internal suspend fun begin(attemptId: String) { + val access = currentCoroutineContext()[Access] + check(access?.store === this && access.attemptId == attemptId && access.active.get()) { + "Profile removal admission is required" + } + access.job.ensureActive() + store.updateData { + check(it.attemptId == null || it.attemptId == attemptId) { "Profile removal attempt changed" } + it.copy(attemptId = attemptId) + } + observationRevision.update { it + 1 } + } + + /** Caller must retain removal ownership until all acknowledged teardown's local persistence has completed. */ + internal suspend fun complete(attemptId: String) { + val access = currentCoroutineContext()[Access] + check(access?.store === this && access.attemptId == attemptId && access.active.get()) { + "Profile removal admission is required" + } + store.updateData { + check(it.attemptId == attemptId) { "Profile removal attempt changed" } + ProfileRemovalState() + } + observationRevision.update { it + 1 } + } + + private class Access( + val store: ProfileRemovalStore, + val attemptId: String, + val job: Job, + ) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + val active = AtomicBoolean(true) + } +} + +@Serializable +internal data class ProfileRemovalState(val version: Int = 1, val attemptId: String? = null) + +/** Malformed local admission data is replaced only with a new, still-blocking recovery obligation. */ +internal fun profileRemovalCorruptionHandler() = ReplaceFileCorruptionHandler { + ProfileRemovalState(attemptId = UUID.randomUUID().toString()) +} + +internal class UnsupportedProfileRemovalFormat : IOException("Update Bitkit to recover this profile cleanup state") + +internal object ProfileRemovalSerializer : Serializer { + override val defaultValue = ProfileRemovalState() + + override suspend fun readFrom(input: InputStream): ProfileRemovalState { + val bytes = input.readBytes() + return try { + val envelope = json.parseToJsonElement(bytes.decodeToString(throwOnInvalidSequence = true)) as? JsonObject + ?: throw CorruptionException("Profile recovery state is not an object") + envelope["version"]?.let { version -> + if (version !is JsonPrimitive || version.isString || version.intOrNull != 1) { + throw UnsupportedProfileRemovalFormat() + } + } + json.decodeFromJsonElement(envelope).also { + require(it.attemptId == null || UUID.fromString(it.attemptId).toString() == it.attemptId) { + "Invalid profile removal attempt" + } + } + } catch (error: CharacterCodingException) { + throw CorruptionException("Profile recovery state has invalid text encoding", error) + } catch (error: IllegalArgumentException) { + throw CorruptionException("Profile recovery state could not be read", error) + } + } + + override suspend fun writeTo(t: ProfileRemovalState, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} + +internal class ProfileRemovalPending : AppError( + "Profile cleanup is pending. Disconnect the profile to finish local recovery." +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index f9ea2491d3..c3471463fd 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import android.content.Context import com.synonym.bitkitcore.UsdtException +import com.synonym.paykit.PaykitException import com.synonym.vssclient.VssException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -1275,11 +1276,15 @@ private data class RestoredCoreBackup( val needsRewrite: Boolean, ) -internal fun Throwable.isTemporaryBackupFailure(): Boolean = isPaykitTemporarilyUnavailable() || when (this) { - is VssException.ConnectionException, - is VssException.NetworkException, - is VssException.StoreException, - is VssException.PutException, - -> true - else -> false -} +internal fun Throwable.isTemporaryBackupFailure(): Boolean = isPaykitTemporarilyUnavailable() || + // Only backup capture may retry this admission rejection; profile mutations must reconcile separately. + generateSequence(this) { it.cause }.any { + it is PaykitException.Storage && it.code == "profile_removal_in_progress" + } || when (this) { + is VssException.ConnectionException, + is VssException.NetworkException, + is VssException.StoreException, + is VssException.PutException, + -> true + else -> false + } diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index 1145ebfad9..9b76c49eb0 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -15,6 +15,8 @@ import to.bitkit.data.paykitDisabled import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching import to.bitkit.services.PaykitSdkOperationLock.Priority +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -27,27 +29,70 @@ class ContactPaymentSettingsRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { private val sharingMutex = Mutex() + private val removalPending = AtomicBoolean(false) + private val wipePending = AtomicBoolean(false) + private val sharingEpoch = AtomicLong(0) + + internal suspend fun withProfileRemoval(operation: suspend () -> T): T { + check(!wipePending.get()) { "Wallet wipe is in progress" } + val epoch = sharingEpoch.get() + check(removalPending.compareAndSet(false, true)) { "Profile removal is already in progress" } + sharingEpoch.incrementAndGet() + try { + return sharingMutex.withLock { + check(!wipePending.get() && sharingEpoch.get() == epoch + 1) { "Profile sharing state changed" } + operation() + } + } finally { + removalPending.set(false) + } + } + + /** Close sharing admission first, revoke repository removal, then drain sharing before taking the SDK barrier. */ + internal suspend fun withWalletWipe( + admitRepository: suspend (suspend () -> T) -> T, + operation: suspend () -> T, + ): T { + check(wipePending.compareAndSet(false, true)) { "Wallet wipe is already in progress" } + sharingEpoch.incrementAndGet() + try { + return admitRepository { sharingMutex.withLock { operation() } } + } finally { + wipePending.set(false) + } + } + + private suspend fun changeSharing(operation: suspend () -> Result): Result = runSuspendCatching { + val epoch = sharingEpoch.get() + check(!removalPending.get() && !wipePending.get()) { "Profile removal is in progress" } + sharingMutex.withLock { + check( + !removalPending.get() && !wipePending.get() && epoch == sharingEpoch.get() + ) { "Profile sharing state changed" } + operation().getOrThrow() + } + } val isEnabled: Flow = settingsStore.data.map { it.areContactPaymentsEnabled() } suspend fun setEnabled(isEnabled: Boolean): Result = withContext(ioDispatcher) { - sharingMutex.withLock { + changeSharing { val contacts = pubkyRepo.contacts.value.map { it.publicKey } if (isEnabled) enable(contacts) else disable(contacts) } } suspend fun reconcilePendingEndpoints(reconcile: suspend () -> Unit) = withContext(ioDispatcher) { - if (!sharingMutex.tryLock()) return@withContext + if (removalPending.get() || wipePending.get() || !sharingMutex.tryLock()) return@withContext try { - reconcile() + if (!removalPending.get() && !wipePending.get()) reconcile() } finally { sharingMutex.unlock() } } suspend fun disablePaykit(): Result = withContext(ioDispatcher) { - sharingMutex.withLock { + changeSharing { runSuspendCatching { val previous = settingsStore.data.first() val hadPublicState = previous.hasPublicPaykitPublicationState() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index 47c01cdf88..70aa242485 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -4,7 +4,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.ProfileRemovalStore import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Provider @@ -16,7 +18,21 @@ class PrivatePaykitContactResolver @Inject constructor( private val cacheStore: PrivatePaykitCacheStore, private val addressReservationRepo: Provider, private val paymentRequestRepo: Provider, + private val profileRemovalStore: ProfileRemovalStore, ) { + internal val recoveryGeneration: Long get() = profileRemovalStore.generation + + private suspend fun isRecoveryCurrent(generation: Long): Boolean = + hasProfileAccess() && generation == recoveryGeneration + + internal suspend fun hasProfileAccess(): Boolean = + runSuspendCatching { profileRemovalStore.requireNoPending() }.isSuccess + + /** Wallet address accounting is independent of whether its old profile may be attributed. */ + internal suspend fun isReservedReceiveAddress(address: String): Boolean = withContext(ioDispatcher) { + addressReservationRepo.get().contactPublicKeyForReservedAddress(address) != null + } + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts get() = paymentRequestRepo.get().receivedPaymentContacts @@ -28,7 +44,8 @@ class PrivatePaykitContactResolver @Inject constructor( suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(ioDispatcher) { - if (paymentHash.isBlank()) return@withContext null + val generation = recoveryGeneration + if (paymentHash.isBlank() || !hasProfileAccess()) return@withContext null val shared = receivedPaymentContacts val contacts = cacheStore.data.first().contacts.mapNotNull { (publicKey, contactState) -> publicKey.takeIf { @@ -36,13 +53,16 @@ class PrivatePaykitContactResolver @Inject constructor( paymentHash in contactState.receivedInvoicePaymentHashes } } - if (receivedPaymentContacts !== shared) return@withContext null + if (receivedPaymentContacts !== shared || !isRecoveryCurrent(generation)) return@withContext null (contacts + shared.contactsForPaymentHash(paymentHash)) .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } suspend fun contactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { - addressReservationRepo.get().contactPublicKeyForReservedAddress(address) + val generation = recoveryGeneration + if (!hasProfileAccess()) return@withContext null + val contact = addressReservationRepo.get().contactPublicKeyForReservedAddress(address) + contact.takeIf { isRecoveryCurrent(generation) } } suspend fun contactPublicKeyForPrivateOnchainAddresses( @@ -50,7 +70,10 @@ class PrivatePaykitContactResolver @Inject constructor( addresses: Collection, ): String? = withContext(ioDispatcher) { - if (receivingAddress.isNullOrBlank() || receivingAddress !in addresses) return@withContext null + val generation = recoveryGeneration + if (receivingAddress.isNullOrBlank() || receivingAddress !in addresses || !hasProfileAccess()) { + return@withContext null + } val shared = receivedPaymentContacts val reservedContacts = addresses.distinct().associateWith { contactPublicKeyForReservedAddress(it) @@ -59,7 +82,7 @@ class PrivatePaykitContactResolver @Inject constructor( shared.contactsForAddresses(listOf(receivingAddress)) val contact = receivingContacts.mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() ?: return@withContext null - if (receivedPaymentContacts !== shared) return@withContext null + if (receivedPaymentContacts !== shared || !isRecoveryCurrent(generation)) return@withContext null contact.takeIf { (reservedContacts.values.filterNotNull() + shared.contactsForAddresses(addresses)) .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() == contact diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 02dc4fbb55..6ab085472a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext @@ -41,6 +42,7 @@ import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.App import to.bitkit.async.appScope import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.di.json @@ -87,6 +89,7 @@ class PrivatePaykitRepo @Inject constructor( private val usdtRepo: UsdtRepo, private val coreService: CoreService, private val clock: Clock, + private val profileRemovalStore: ProfileRemovalStore, ) { companion object { private const val TAG = "PrivatePaykitRepo" @@ -126,10 +129,12 @@ class PrivatePaykitRepo @Inject constructor( private val activeLinkPreparations = mutableMapOf>() private var preparationJob: Job? = null private var preparationGeneration = 0 - private val _isProfileDeletionInProgress = MutableStateFlow(false) + private val profileDeletionPauses = mutableSetOf() + private var recoveryPending = true + private val _isProfileDeletionInProgress = MutableStateFlow(true) val isProfileDeletionInProgress = _isProfileDeletionInProgress.asStateFlow() - private val isDeletingProfile: Boolean - get() = _isProfileDeletionInProgress.value + private val profileRecoveryReady = CompletableDeferred() + private var pendingLightningRefresh: LightningRefresh? = null private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null @@ -220,6 +225,16 @@ class PrivatePaykitRepo @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + init { + retryScope.launch { + profileRemovalStore.pending.collect { pending -> + recoveryPending = pending + _isProfileDeletionInProgress.value = pending || profileDeletionPauses.isNotEmpty() + profileRecoveryReady.complete(Unit) + } + } + } + suspend fun reconcileReservedReceiveIndexes(): Result = addressReservationRepo.reconcileReservedIndexesWithLdk() @@ -302,12 +317,12 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun scheduleContactPreparation( + private suspend fun scheduleContactPreparation( publicKeys: Collection, forceRefreshLightning: Boolean = false, requeueActive: Boolean = false, ) { - if (isDeletingProfile) return + if (isDeletingProfile()) return pendingPreparationKeys.addAll( publicKeys.filter { requeueActive || forceRefreshLightning || it !in activePreparationKeys } ) @@ -359,13 +374,38 @@ class PrivatePaykitRepo @Inject constructor( clearPendingMessageDrainRetries() } - suspend fun beginProfileDeletion() = withContext(serializedDispatcher) { - _isProfileDeletionInProgress.update { true } - invalidateContactPreparation() + internal class ProfileDeletionPause internal constructor() + + internal suspend fun beginProfileDeletion(): ProfileDeletionPause { + val pause = ProfileDeletionPause() + try { + withContext(serializedDispatcher) { + profileDeletionPauses.add(pause) + _isProfileDeletionInProgress.value = true + invalidateContactPreparation() + } + return pause + } catch (error: Throwable) { + // Cancellation can discard the return dispatch after the pause was already installed. + withContext(NonCancellable) { endProfileDeletion(pause) } + throw error + } } - suspend fun endProfileDeletion() = withContext(serializedDispatcher) { - _isProfileDeletionInProgress.update { false } + internal suspend fun endProfileDeletion(pause: ProfileDeletionPause) = withContext(serializedDispatcher) { + profileDeletionPauses.remove(pause) + _isProfileDeletionInProgress.value = recoveryPending || profileDeletionPauses.isNotEmpty() + } + + internal suspend fun withProfileDeletionPaused( + operation: suspend (ProfileDeletionPause) -> T, + ): T { + val pause = beginProfileDeletion() + try { + return operation(pause) + } finally { + withContext(NonCancellable) { endProfileDeletion(pause) } + } } suspend fun refreshSavedContactEndpoints( @@ -377,7 +417,7 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runContactSync { val normalizedKey = normalizedPublicKey(publicKey) ?: return@runContactSync - if (isDeletingProfile || isContactSharingCleanupPending()) return@runContactSync + if (isDeletingProfile() || isContactSharingCleanupPending()) return@runContactSync checkPaykitContactSync(isStillCurrent) rememberSavedContacts(savedPublicKeys + normalizedKey, replacing = false) schedulePendingPrivateMessageDrainRetries( @@ -412,7 +452,8 @@ class PrivatePaykitRepo @Inject constructor( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (isDeletingProfile) return@runSuspendCatching + if (isDeletingProfile()) return@runSuspendCatching + profileRemovalStore.requireNoPending() val settings = settingsStore.data.first() val hasDisabledPublications = !settings.sharesPrivatePaykitEndpoints && hasPublishedPrivateEndpoints() val cleanupPending = isContactSharingCleanupPending() @@ -459,7 +500,7 @@ class PrivatePaykitRepo @Inject constructor( pendingPreparationKeys.removeAll(keys) keys.forEach { pendingMessageDrainRetries.remove(it)?.job?.cancel() } keys.forEach(unavailableLinkRetryAt::remove) - if (!isDeletingProfile) { + if (!isDeletingProfile()) { removePublishedEndpoints(keys, isStillCurrent).onFailure { checkPaykitContactSync(isStillCurrent) updateDeletedContactCleanupPending(keys, true, isStillCurrent) @@ -469,7 +510,7 @@ class PrivatePaykitRepo @Inject constructor( clearContactStates(keys, isStillCurrent) checkPaykitContactSync(isStillCurrent) addressReservationRepo.removeContactAssignments(keys, isStillCurrent) - if (!isDeletingProfile) updateDeletedContactCleanupPending(keys, false, isStillCurrent) + if (!isDeletingProfile()) updateDeletedContactCleanupPending(keys, false, isStillCurrent) } } @@ -519,21 +560,42 @@ class PrivatePaykitRepo @Inject constructor( suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { + profileRemovalStore.requireAccess() pendingLightningRefresh = null invalidateContactPreparation() publicationMutex.withLock { - clearPendingMessageDrainRetries() - knownSavedContactKeys.clear() - unavailableLinkRetryAt.clear() - state = PrivatePaykitState() - cacheStore.reset() - addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) + clearLocalPaymentState() paykitSdkService.clearState() notifyBackupStateChanged() } } } + /** Native sign-out already reset the SDK; complete only the local state owned by this removal. */ + internal suspend fun finishProfileRemoval(pause: ProfileDeletionPause): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + profileRemovalStore.requireCompletionAccess() + check(pause in profileDeletionPauses) { "Profile removal has already finished" } + pendingLightningRefresh = null + invalidateContactPreparation() + publicationMutex.withLock { + check(pause in profileDeletionPauses) { "Profile removal has already finished" } + clearLocalPaymentState() + notifyBackupStateChanged() + } + } + } + + private suspend fun clearLocalPaymentState() { + clearPendingMessageDrainRetries() + knownSavedContactKeys.clear() + unavailableLinkRetryAt.clear() + state = PrivatePaykitState() + cacheStore.reset() + addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) + } + suspend fun beginSavedContactPayment(publicKey: String): Result = withContext(serializedDispatcher) { runSuspendCatching { @@ -800,18 +862,22 @@ class PrivatePaykitRepo @Inject constructor( suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(serializedDispatcher) { - if (paymentHash.isBlank()) return@withContext null - ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> - publicKey.takeIf { - contactState.localInvoice?.paymentHash == paymentHash || - paymentHash in contactState.receivedInvoicePaymentHashes + runSuspendCatching { + profileRemovalStore.requireNoPending() + if (paymentHash.isBlank()) return@runSuspendCatching null + ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> + publicKey.takeIf { + contactState.localInvoice?.paymentHash == paymentHash || + paymentHash in contactState.receivedInvoicePaymentHashes + } } - } + }.getOrNull() } internal suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runSuspendCatching { + profileRemovalStore.requireNoPending() val sdkVersion = paykitSdkService.backupStateVersion.value pubkyService.currentPublicKey() ?: return@runSuspendCatching PrivatePaykitBackupSnapshot(null, sdkVersion) @@ -833,6 +899,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { runSuspendCatching { + profileRemovalStore.requireNoPending() val decoded = backup?.let { json.decodeFromString(it) } decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } pendingLightningRefresh = null @@ -1199,7 +1266,7 @@ class PrivatePaykitRepo @Inject constructor( status: IdentityStatus? = null, ): Boolean { val currentIdentity = status?.publicKey ?: pubkyService.currentPublicKey() - val isCurrent = !isDeletingProfile && generation == preparationGeneration && currentIdentity == identity + val isCurrent = !isDeletingProfile() && generation == preparationGeneration && currentIdentity == identity if (!isCurrent && requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return isCurrent } @@ -1329,9 +1396,9 @@ class PrivatePaykitRepo @Inject constructor( generation: Int, priority: Priority = Priority.Ordered, ) { - if (isDeletingProfile || generation != preparationGeneration) return + if (isDeletingProfile() || generation != preparationGeneration) return if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile()) return - if (isDeletingProfile || generation != preparationGeneration) return + if (isDeletingProfile() || generation != preparationGeneration) return drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct(), generation, priority) } @@ -1343,12 +1410,12 @@ class PrivatePaykitRepo @Inject constructor( ) { val newKeys = retryKeys.toSet() - pendingMessageDrainRetries.keys val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true, priority = priority) - if (pendingKeys.isEmpty() || isDeletingProfile || generation != preparationGeneration) return + if (pendingKeys.isEmpty() || isDeletingProfile() || generation != preparationGeneration) return drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = priority) - if (isDeletingProfile || generation != preparationGeneration) return + if (isDeletingProfile() || generation != preparationGeneration) return val pendingRetryKeys = pendingPrivateMessageDrainKeys(pendingKeys, priority = priority) - if (pendingRetryKeys.isNotEmpty() && !isDeletingProfile && generation == preparationGeneration) { + if (pendingRetryKeys.isNotEmpty() && !isDeletingProfile() && generation == preparationGeneration) { schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys) } } @@ -1534,7 +1601,7 @@ class PrivatePaykitRepo @Inject constructor( explicit: Boolean = false, identity: String? = null, ) { - if (retryKeys.isEmpty() || isDeletingProfile) return + if (retryKeys.isEmpty() || isDeletingProfile()) return if (!explicit && retryKeys.all { it in pendingMessageDrainRetries }) return val generation = preparationGeneration val identity = identity ?: pubkyService.currentPublicKey() ?: return @@ -2216,21 +2283,26 @@ class PrivatePaykitRepo @Inject constructor( return paymentHash in paymentHashes } + private suspend fun isDeletingProfile(): Boolean { + profileRecoveryReady.await() + return _isProfileDeletionInProgress.value + } + private suspend fun canPublishPrivateEndpoints(status: IdentityStatus? = null): Boolean { - if (isDeletingProfile) return false + if (isDeletingProfile()) return false val settings = settingsStore.data.first() val locallyEligible = settings.sharesPrivatePaykitEndpoints && !isContactSharingCleanupPending() && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() - if (!locallyEligible || isDeletingProfile) return false + if (!locallyEligible || isDeletingProfile()) return false val hasAccess = if (status?.publicKey != null) { status.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } else { hasPrivatePaymentAccessForCurrentProfile() } - return hasAccess && !isDeletingProfile + return hasAccess && !isDeletingProfile() } private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { @@ -2382,8 +2454,12 @@ class PrivatePaykitRepo @Inject constructor( private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) private suspend fun ensureState(): PrivatePaykitState { + profileRemovalStore.requireAccess() state?.let { return it } - return PrivatePaykitState(cacheStore.data.first()).also { state = it } + val loaded = PrivatePaykitState(cacheStore.data.first()) + profileRemovalStore.requireAccess() + // A teardown or restore may have installed a newer state while this cold read was suspended. + return state ?: loaded.also { state = it } } private suspend fun persistState( @@ -2391,6 +2467,7 @@ class PrivatePaykitRepo @Inject constructor( preserveCleanupMarkers: Boolean = true, isStillCurrent: (() -> Boolean)? = null, ) { + profileRemovalStore.requireAccess() val currentState = state ?: PrivatePaykitState() cacheStore.update { if (isStillCurrent?.invoke() == false) return@update it diff --git a/app/src/main/java/to/bitkit/repositories/PubkyIdentityOperations.kt b/app/src/main/java/to/bitkit/repositories/PubkyIdentityOperations.kt new file mode 100644 index 0000000000..755d86162f --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PubkyIdentityOperations.kt @@ -0,0 +1,126 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext +import to.bitkit.utils.AppError +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** Admission covers local completion and rollback as well as the native session change. */ +internal class PubkyIdentityOperations(private val beforeTransition: suspend () -> Unit = {}) { + private val lock = Any() + private val active = mutableSetOf() + private var removal: Operation? = null + private var wiping = false + private var wipeOperation: Operation? = null + + suspend fun transition(block: suspend () -> T): T { + currentCoroutineContext()[Operation]?.takeIf { it.owner === this }?.let { + check(synchronized(lock) { it in active }) { "Pubky operation has already finished" } + return block() + } + val operation = synchronized(lock) { + if (wiping || removal != null) throw PubkyIdentityBusy + Operation(this).also(active::add) + } + return run(operation) { + beforeTransition() + block() + } + } + + suspend fun remove(block: suspend () -> T): T { + val operation = synchronized(lock) { + if (wiping || active.isNotEmpty()) throw PubkyIdentityBusy + Operation(this).also { + removal = it + active.add(it) + } + } + return run(operation, block) + } + + suspend fun requireRemoval() { + val operation = currentCoroutineContext()[Operation] + check(synchronized(lock) { operation != null && operation === removal && operation in active }) { + "Profile removal admission is required" + } + } + + suspend fun requireWipe() { + val operation = currentCoroutineContext()[Operation] + check(synchronized(lock) { operation != null && operation === wipeOperation && operation in active }) { + "Wallet wipe admission is required" + } + } + + suspend fun wipe(block: suspend () -> T): T { + check(currentCoroutineContext()[Operation]?.owner !== this) { "Pubky operation cannot start a wallet wipe" } + val (draining, cancelled) = synchronized(lock) { + if (wiping) throw PubkyIdentityBusy + wiping = true + active.toList() to removal + } + // Revoke and drain before the SDK barrier; no monitor or session mutex is held while awaiting cleanup. + cancelled?.cancel() + try { + draining.forEach { it.drained.await() } + val operation = synchronized(lock) { + Operation(this).also { + active.add(it) + wipeOperation = it + } + } + return run(operation, block) + } finally { + synchronized(lock) { + wiping = false + wipeOperation = null + } + } + } + + private suspend fun run(operation: Operation, block: suspend () -> T): T { + try { + return withContext(operation) { + operation.bind(requireNotNull(currentCoroutineContext()[Job])) + block() + } + } finally { + synchronized(lock) { + active.remove(operation) + if (removal === operation) removal = null + } + operation.drained.complete(Unit) + } + } + + private class Operation(val owner: PubkyIdentityOperations) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + + private val lock = Any() + private var job: Job? = null + private var cancelled = false + val drained = CompletableDeferred() + + fun bind(job: Job) { + val cancelNow = synchronized(lock) { + this.job = job + cancelled + } + if (cancelNow) job.cancel() + } + + fun cancel() { + val current = synchronized(lock) { + cancelled = true + job + } + current?.cancel() + } + } +} + +internal data object PubkyIdentityBusy : AppError("Pubky identity work is already in progress") diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0ddfd7e599..784f1e0c6e 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -43,13 +43,13 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.async.appScope +import to.bitkit.data.ProfileRemovalPending +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyCachedProfile import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore -import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain -import to.bitkit.data.paykitDisabled import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract import to.bitkit.di.IoDispatcher @@ -68,6 +68,7 @@ import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.services.PaykitProfileDeletionSnapshot import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitReadTimeoutError import to.bitkit.services.PubkyService @@ -101,6 +102,18 @@ sealed class PubkyContactError(message: String) : AppError(message) { */ class PubkySignIn internal constructor(val publicKey: String, internal val generation: Long) +/** One displayed identity, including a saved identity whose session could not be restored. */ +class PubkyProfileOwner internal constructor( + internal val signIn: PubkySignIn?, + internal val storedRevision: Long, +) + +internal class PubkyProfileDeletion( + val owner: PubkyProfileOwner, + val profileWriteRevision: Long, + val snapshot: PaykitProfileDeletionSnapshot, +) + private fun Throwable.hasCause(error: AppError): Boolean = generateSequence(this) { it.cause }.any { it == error } @@ -119,6 +132,9 @@ enum class PubkyIdentityReadiness { /** An identity is saved, or could not be checked, but its session could not be restored yet. */ Unavailable, + + /** An interrupted local removal must be completed without activating saved credentials. */ + RecoveryRequired, } @OptIn(ExperimentalTime::class) @@ -134,6 +150,7 @@ class PubkyRepo @Inject constructor( private val settingsStore: SettingsStore, private val httpClient: HttpClient, private val clock: Clock, + private val profileRemovalStore: ProfileRemovalStore, ) { companion object { private const val TAG = "PubkyRepo" @@ -164,6 +181,7 @@ class PubkyRepo @Inject constructor( private val scope = appScope(ioDispatcher, TAG) private val serviceInitializeMutex = Mutex() private val initializeMutex = Mutex() + private val identityOperations = PubkyIdentityOperations(::requireNoPendingRemoval) private val deferredRestoreMutex = Mutex() private var deferredRestoreGeneration: Long? = null private val completedRestoreVersion = AtomicLong() @@ -250,12 +268,21 @@ class PubkyRepo @Inject constructor( val isAuthenticated: StateFlow = _publicKey.map { it != null } .stateIn(scope, SharingStarted.Eagerly, false) + val profileRecoveryPending: StateFlow = profileRemovalStore.pending + .map { it } + .stateIn(scope, SharingStarted.Eagerly, null) + + suspend fun requiresProfileRecovery(): Boolean = withContext(ioDispatcher) { + runSuspendCatching { profileRemovalStore.requireNoPending() }.isFailure + } + val identityExists: StateFlow = combine( _publicKey, _backupStateVersion, _identityRefreshVersion, - ) { _, _, _ -> - runSuspendCatching { hasIdentity() } + profileRemovalStore.pending, + ) { _, _, _, pending -> + pending || runSuspendCatching { hasIdentity() } .onFailure { Logger.warn("Failed to check saved identity", it, context = TAG) } .getOrDefault(true) }.stateIn(scope, SharingStarted.Eagerly, null) @@ -342,6 +369,61 @@ class PubkyRepo @Inject constructor( } } + private suspend fun requireNoPendingRemoval() { + runSuspendCatching { profileRemovalStore.requireNoPending() }.exceptionOrNull()?.let { failure -> + // Retained credentials must not activate while local teardown is unresolved or unreadable. + _isRestoringSession.value = false + _sessionRestorationFailed.value = true + runSuspendCatching { pubkyService.preparePublicProfileRecovery() } + .onFailure { Logger.warn("Failed to prepare public profile access", it, context = TAG) } + throw failure + } + } + + private suspend fun identityTransition(operation: suspend () -> Result): Result = + runSuspendCatching { identityOperations.transition { operation().getOrThrow() } } + + fun profileOwner(): PubkyProfileOwner { + val revision = storedIdentityRevision.get() + return PubkyProfileOwner(currentSignIn(), revision) + } + + fun isProfileOwnerCurrent(owner: PubkyProfileOwner): Boolean = + owner.storedRevision == storedIdentityRevision.get() && + (owner.signIn?.let(::isCurrent) ?: (currentSignIn() == null)) + + /** Reserve the original identity through native work, local completion and cancellation cleanup. */ + internal suspend fun performProfileRemoval( + owner: PubkyProfileOwner, + operation: suspend () -> Unit, + ): Result = runSuspendCatching { + identityOperations.remove { + check(isProfileOwnerCurrent(owner)) { "The profile identity changed" } + pubkyService.withProfileRemoval({ isProfileOwnerCurrent(owner) }, operation) + profileOwner() + } + } + + /** UUID access is granted only after the repository has admitted the captured owner. */ + internal suspend fun performProfileRemoval( + owner: PubkyProfileOwner, + attemptId: String, + operation: suspend () -> Unit, + ): Result = runSuspendCatching { + identityOperations.remove { + check(isProfileOwnerCurrent(owner)) { "The profile identity changed" } + profileRemovalStore.withAccess(attemptId) { + pubkyService.preparePublicProfileRecovery() + pubkyService.withProfileRemoval({ isProfileOwnerCurrent(owner) }, operation) + } + profileOwner() + } + } + + /** Close session admission before the native wipe barrier, retaining admitted rollback/local tails. */ + internal suspend fun withWalletWipeAdmission(operation: suspend () -> T): T = + identityOperations.wipe(operation) + // region Initialization suspend fun awaitInitialization() = withContext(ioDispatcher) { @@ -349,7 +431,10 @@ class PubkyRepo @Inject constructor( } suspend fun republishIdentityIfNeeded(): Result = withContext(ioDispatcher) { - runSuspendCatching { pubkyService.republishIdentityIfNeeded(publicKey.value) } + runSuspendCatching { + profileRemovalStore.requireNoPending() + pubkyService.republishIdentityIfNeeded(publicKey.value) + } } suspend fun initialize() = initializeForRevision() @@ -372,7 +457,10 @@ class PubkyRepo @Inject constructor( job.start() } - private suspend fun initializeForRevision( + private suspend fun initializeForRevision(expectedRevision: Long? = null): Result = + identityTransition { initializeForRevisionAdmitted(expectedRevision) } + + private suspend fun initializeForRevisionAdmitted( expectedRevision: Long? = null, ): Result = withContext(ioDispatcher) { val (restored, revision) = initializeMutex.withLock { @@ -387,7 +475,15 @@ class PubkyRepo @Inject constructor( checkAdoptedSource() } - suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { + suspend fun restoreSessionIfNeeded(): Boolean = try { + identityOperations.transition { restoreSessionIfNeededAdmitted() } + } catch (_: PubkyIdentityBusy) { + false + } catch (_: ProfileRemovalPending) { + false + } + + private suspend fun restoreSessionIfNeededAdmitted() = withContext(ioDispatcher) { awaitInitialization() val restored = restoreSession() if (restored) loadIdentityData() @@ -418,6 +514,7 @@ class PubkyRepo @Inject constructor( */ suspend fun awaitIdentityReady(): PubkyIdentityReadiness = withContext(ioDispatcher) { awaitInitialization() + if (requiresProfileRecovery()) return@withContext PubkyIdentityReadiness.RecoveryRequired if (_publicKey.value != null) return@withContext PubkyIdentityReadiness.Ready scope.async { val restored = restoreSession() @@ -439,7 +536,15 @@ class PubkyRepo @Inject constructor( } } - private suspend fun restoreSession(expectedGeneration: Long? = null): Boolean { + private suspend fun restoreSession(expectedGeneration: Long? = null): Boolean = try { + identityOperations.transition { restoreSessionAdmitted(expectedGeneration) } + } catch (_: PubkyIdentityBusy) { + false + } catch (_: ProfileRemovalPending) { + false + } + + private suspend fun restoreSessionAdmitted(expectedGeneration: Long?): Boolean { val version = completedRestoreVersion.get() return initializeMutex.withLock { if (expectedGeneration != null && @@ -618,7 +723,10 @@ class PubkyRepo @Inject constructor( _adoptedSourceLost.update { false } } - suspend fun checkAdoptedSource(): Result = withContext(ioDispatcher) { + suspend fun checkAdoptedSource(): Result = + identityTransition { checkAdoptedSourceAdmitted() } + + private suspend fun checkAdoptedSourceAdmitted(): Result = withContext(ioDispatcher) { runSuspendCatching { if (initializationReady.isCompleted) checkAdoptedSourcePresent() }.onFailure { Logger.warn("Failed to check adopted ring identity", it, context = TAG) } @@ -658,6 +766,12 @@ class PubkyRepo @Inject constructor( suspend fun adoptRingIdentity( pubky: String, knownProfile: () -> PubkyProfile? = { null }, + ): Result = + identityTransition { adoptRingIdentityAdmitted(pubky, knownProfile) } + + private suspend fun adoptRingIdentityAdmitted( + pubky: String, + knownProfile: () -> PubkyProfile? = { null }, ): Result = withContext(ioDispatcher) { val reference = "${SharedPubkyContract.RING_SOURCE_PREFIX}$pubky" adoptionMutex.withLock { @@ -919,6 +1033,15 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, + ): Result = + identityTransition { createIdentityAdmitted(name, bio, links, tags, avatarBytes) } + + private suspend fun createIdentityAdmitted( + name: String, + bio: String, + links: List, + tags: List, + avatarBytes: ByteArray?, ): Result { val result = initializeMutex.withLock { if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) { @@ -1064,6 +1187,15 @@ class PubkyRepo @Inject constructor( links: List, tags: List, imageUrl: String?, + ): Result = + identityTransition { saveProfileAdmitted(name, bio, links, tags, imageUrl) } + + private suspend fun saveProfileAdmitted( + name: String, + bio: String, + links: List, + tags: List, + imageUrl: String?, ): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { @@ -1086,22 +1218,36 @@ class PubkyRepo @Inject constructor( } } - suspend fun deleteProfile(): Result = runSuspendCatching { - withContext(ioDispatcher) { - requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { - "No session available" + internal suspend fun prepareProfileDeletion(owner: PubkyProfileOwner): Result = + runSuspendCatching { + check(isProfileOwnerCurrent(owner)) { "Profile removal identity changed" } + val signIn = requireNotNull(owner.signIn) { "Profile identity is not available" } + val revision = profileWriteGeneration.get() + val snapshot = pubkyService.captureProfileDeletion(signIn.publicKey) { isProfileOwnerCurrent(owner) } + check(isProfileOwnerCurrent(owner) && revision == profileWriteGeneration.get()) { + "Profile changed; review it before removing it" } - clearSessionContactProfiles() - deleteAllContacts() - pubkyService.deletePaykitProfile() - profileWriteGeneration.incrementAndGet() + PubkyProfileDeletion(owner, revision, snapshot) + } + + internal suspend fun validateProfileDeletion(deletion: PubkyProfileDeletion) { + identityOperations.requireRemoval() + check(isProfileOwnerCurrent(deletion.owner) && deletion.profileWriteRevision == profileWriteGeneration.get()) { + "Profile changed; review it before removing it" } - settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPaykitState()) } - signOut().getOrThrow() + pubkyService.validateProfileDeletion(deletion.snapshot) } - private suspend fun deleteAllContacts() { - val records = pubkyService.contactRecords() + internal suspend fun deleteProfile(deletion: PubkyProfileDeletion): Result = runSuspendCatching { + identityOperations.requireRemoval() + check(isProfileOwnerCurrent(deletion.owner)) { "Profile removal identity changed" } + clearSessionContactProfiles() + deleteContacts(deletion.snapshot.contacts) + pubkyService.deletePaykitProfile(deletion.snapshot) + profileWriteGeneration.incrementAndGet() + } + + private suspend fun deleteContacts(records: List) { if (records.isEmpty()) return val keys = records.map { it.publicKey.ensurePubkyPrefix() }.toSet() val removedKeys = pubkyService.removeContacts(records.map { it.publicKey }) @@ -1542,8 +1688,9 @@ class PubkyRepo @Inject constructor( // region Auth approval suspend fun hasSecretKey(): Boolean = runSuspendCatching { - val publicKey = _publicKey.value ?: return@runSuspendCatching false - managedSecretKeyFor(publicKey) != null + val owner = profileOwner() + if (owner.signIn == null) return@runSuspendCatching false + activeSecretKeyHex(owner, clearInvalidKey = false) != null }.getOrDefault(false) suspend fun hasStoredSecretKey(): Boolean = withContext(ioDispatcher) { @@ -1620,7 +1767,10 @@ class PubkyRepo @Inject constructor( } } - suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { + suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = + identityTransition { approveSignupAuthAdmitted(request) } + + private suspend fun approveSignupAuthAdmitted(request: PubkyAuthRequest): Result = initializeMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { require(request.isSignup) { "Not a Pubky signup request" } @@ -1681,6 +1831,13 @@ class PubkyRepo @Inject constructor( authUrl: String, expectedCapabilities: String, approvedClientId: String, + ): Result = + identityTransition { approveAuthAdmitted(authUrl, expectedCapabilities, approvedClientId) } + + private suspend fun approveAuthAdmitted( + authUrl: String, + expectedCapabilities: String, + approvedClientId: String, ): Result = runSuspendCatching { withContext(ioDispatcher) { val secretKeyHex = requireNotNull(activeSecretKeyHex()) { @@ -1694,6 +1851,13 @@ class PubkyRepo @Inject constructor( authUrl: String, approvedClientId: String, unsignedPayload: ByteArray, + ): Result = + identityTransition { approveAuthWithCompanionClaimAdmitted(authUrl, approvedClientId, unsignedPayload) } + + private suspend fun approveAuthWithCompanionClaimAdmitted( + authUrl: String, + approvedClientId: String, + unsignedPayload: ByteArray, ): Result = runSuspendCatching { withContext(ioDispatcher) { val claim = PubkyAuthRequest.parseBitkitClaim( @@ -1736,7 +1900,12 @@ class PubkyRepo @Inject constructor( } } - suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runSuspendCatching { + suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = + identityTransition { restoreSessionBackupStateAdmitted(backup) } + + private suspend fun restoreSessionBackupStateAdmitted( + backup: PubkySessionBackupV1?, + ): Result = runSuspendCatching { withContext(ioDispatcher) { ensureServiceInitialized() @@ -1791,44 +1960,45 @@ class PubkyRepo @Inject constructor( // region Sign out - suspend fun signOut(): Result = withContext(NonCancellable + ioDispatcher) { - initializeMutex.withLock { - val hadPaykitState = settingsStore.data.first().hasPaykitState() - val endpointCleanupResult = removeBitkitPaymentEndpoints() - .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + /** Remote acknowledgement only. Local safety state is retained until strict credential teardown. */ + internal suspend fun signOutForProfileRemoval() { + identityOperations.requireRemoval() + pubkyService.signOut() + } - val result = runSuspendCatching { - pubkyService.signOut() - }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } - - if (result.isFailure) { - if (hadPaykitState) { - runSuspendCatching { - settingsStore.update { it.copy(publicPaykitCleanupPending = true) } - }.onFailure { - Logger.warn("Failed to mark Paykit state for reconciliation", it, context = TAG) - } - } - return@withLock result - } + /** No native initialization or remote mutation; callers retain admission through private completion. */ + internal suspend fun finishProfileRemovalLocally() { + identityOperations.requireRemoval() + pubkyService.forgetLocalProfileAccess() + clearProfileMetadataAfterCredentialTeardown() + } - clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) - result - } + /** The wallet wipe has acknowledged keychain deletion and still owns both teardown barriers. */ + internal suspend fun finishProfileWipeLocally() { + identityOperations.requireWipe() + pubkyService.requireWalletWipe() + clearProfileMetadataAfterCredentialTeardown() } - suspend fun forgetUnrestoredIdentity(): Result = withContext(NonCancellable + ioDispatcher) { - initializeMutex.withLock { - if (_publicKey.value != null) return@withLock Result.success(false) - runSuspendCatching { - pubkyService.forgetSessionAccess() - clearLocalState() - true - }.onFailure { Logger.error("Failed to forget unrestored Pubky identity", it, context = TAG) } + private suspend fun clearProfileMetadataAfterCredentialTeardown() { + withContext(NonCancellable + ioDispatcher) { + try { + // End asynchronous profile/contact writers before resetting their durable cache. + clearAuthenticatedState(clearCachedProfile = false) + invalidateReadOnlyProfile() + evictPubkyImages() + profileWriteGeneration.incrementAndGet() + clearPublicPaykitSharingState(publicPaykitCleanupPending = false) + pubkyStore.reset() + } finally { + notifyBackupStateChanged() + } } } - suspend fun wipeLocalState() = initializeMutex.withLock { + suspend fun wipeLocalState() = identityOperations.transition { wipeLocalStateAdmitted() } + + private suspend fun wipeLocalStateAdmitted() = initializeMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { pubkyService.forgetSessionAccess() } }.onFailure { @@ -2078,34 +2248,38 @@ class PubkyRepo @Inject constructor( } } - private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) { + private suspend fun managedSecretKeyFor(publicKey: String, clearInvalidKey: Boolean): String? { val bareKey = publicKey.removePrefix(PUBKY_PREFIX) val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?: return@withContext bareKey - .takeIf { - keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) == - "${SharedPubkyContract.RING_SOURCE_PREFIX}$bareKey" - } - ?.let { sharedPubkyClient.ringCredential(it).getOrNull() } + ?: return matchingRingSecretKey(bareKey) - val derivedPublicKey = runCatching { + val derivedPublicKey = runSuspendCatching { pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() }.onFailure { Logger.warn("Ignoring invalid managed secret key for '${redacted(publicKey)}'", it, context = TAG) }.getOrNull() if (derivedPublicKey == publicKey) { - return@withContext secretKeyHex + return secretKeyHex } if (derivedPublicKey != null) { Logger.warn("Ignoring stale managed secret key for '${redacted(publicKey)}'", context = TAG) } + if (!clearInvalidKey) return matchingRingSecretKey(bareKey) + storedIdentityRevision.incrementAndGet() runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } .onSuccess { notifyBackupStateChanged() } - null + return matchingRingSecretKey(bareKey) } + private suspend fun matchingRingSecretKey(bareKey: String): String? = bareKey + .takeIf { + keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) == + "${SharedPubkyContract.RING_SOURCE_PREFIX}$bareKey" + } + ?.let { sharedPubkyClient.ringCredential(it).getOrNull() } + private suspend fun adoptedRingPubky(): String? = runCatching { keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) }.getOrNull() ?.substringAfter(SharedPubkyContract.RING_SOURCE_PREFIX, "") @@ -2118,10 +2292,19 @@ class PubkyRepo @Inject constructor( .getOrNull() } - private suspend fun activeSecretKeyHex(): String? { - val publicKey = _publicKey.value ?: return keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - return managedSecretKeyFor(publicKey) - } + private suspend fun activeSecretKeyHex( + owner: PubkyProfileOwner = profileOwner(), + clearInvalidKey: Boolean = true, + ): String? = + withContext(ioDispatcher) { + // Keep the read/derive/delete decision with the identity that supplied its public key. + initializeMutex.withLock { + if (!isProfileOwnerCurrent(owner)) return@withLock null + val publicKey = owner.signIn?.publicKey + ?: return@withLock keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + managedSecretKeyFor(publicKey, clearInvalidKey) + } + } private suspend fun deriveLocalSecretKeyFromWalletSeed(): String = withContext(ioDispatcher) { val mnemonic = requireNotNull(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59c70edd1..eae5a97f4f 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -395,6 +395,7 @@ class ActivityService( val generation: Long, val activityRevision: Long, val reservationVersion: Long, + val recoveryGeneration: Long, ) internal suspend fun invalidatePaykitContactBackfill() = ServiceQueue.CORE.background { @@ -604,6 +605,10 @@ class ActivityService( suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { val resolver = privatePaykitContactResolver.get() + if (!resolver.hasProfileAccess()) { + lastPaykitContactBackfill = null + return@background false + } val contacts = resolver.receivedPaymentContacts if (contacts === PaykitReceivedPaymentContacts.Empty) { lastPaykitContactBackfill = null @@ -614,6 +619,7 @@ class ActivityService( generation = resolver.receivedPaymentContactsGeneration, activityRevision = paykitActivityRevision, reservationVersion = resolver.reservationVersion, + recoveryGeneration = resolver.recoveryGeneration, ) if (snapshot == lastPaykitContactBackfill) return@background false val activities = getActivities( @@ -647,13 +653,14 @@ class ActivityService( changed } - private fun isCurrentPaykitContactBackfill( + private suspend fun isCurrentPaykitContactBackfill( resolver: PrivatePaykitContactResolver, snapshot: PaykitContactBackfillState, - ): Boolean = resolver.receivedPaymentContacts === snapshot.contacts && + ): Boolean = resolver.hasProfileAccess() && resolver.receivedPaymentContacts === snapshot.contacts && resolver.receivedPaymentContactsGeneration == snapshot.generation && paykitActivityRevision == snapshot.activityRevision && - resolver.reservationVersion == snapshot.reservationVersion + resolver.reservationVersion == snapshot.reservationVersion && + resolver.recoveryGeneration == snapshot.recoveryGeneration private suspend fun attributedPaykitActivity( activity: Activity, @@ -1022,7 +1029,7 @@ class ActivityService( private suspend fun findPrivateReservedAddress(details: BitkitCoreTransactionDetails): String? { for (output in details.outputs) { val address = output.scriptpubkeyAddress ?: continue - if (privatePaykitContactResolver.get().contactPublicKeyForReservedAddress(address) != null) return address + if (privatePaykitContactResolver.get().isReservedReceiveAddress(address)) return address } return null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt index ea3483bc12..7db69e37b8 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.PaykitException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext @@ -9,7 +10,9 @@ import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException -internal class PaykitSdkOperationLock { +internal class PaykitSdkOperationLock( + private val checkRecoveryAccess: suspend () -> Unit = {}, +) { companion object { /** Maximum interactive overtakes before the oldest background operation gets a turn. */ private const val MAX_INTERACTIVE_BYPASSES = 3 @@ -28,20 +31,110 @@ internal class PaykitSdkOperationLock { private var interactiveBypasses = 0 private var generation = 0L private var isWiping = false + private var profileRemoval: ProfileRemoval? = null + private var profileRemovalGeneration = 0L suspend fun withLock(priority: Priority = Priority.Ordered, operation: suspend () -> T): T { + return withLock(priority, allowPublicReadInitialization = false, operation) + } + + /** Only for constructing the unauthenticated handle used by publicRead. */ + suspend fun withPublicReadInitialization(operation: suspend () -> T): T = + withLock(Priority.Ordered, allowPublicReadInitialization = true, operation) + + private suspend fun withLock( + priority: Priority, + allowPublicReadInitialization: Boolean, + operation: suspend () -> T, + ): T { if (ownsWipe()) return operation() + val removal = currentCoroutineContext()[ProfileRemoval] + val removalGeneration = synchronized(stateLock) { profileRemovalGeneration } val admittedGeneration = admit() - acquire(priority) + if (!allowPublicReadInitialization) { + checkRecoveryAccess() + checkProfileRemoval(removal) + } + acquire(priority, admittedGeneration) return try { currentCoroutineContext().ensureActive() checkAdmitted(admittedGeneration) + if (!allowPublicReadInitialization) { + checkRecoveryAccess() + checkProfileRemoval(removal) + if (synchronized(stateLock) { removalGeneration != profileRemovalGeneration }) { + throw removalError() + } + } operation() } finally { release() } } + /** Reserves admission, without holding the SDK lock across caller-owned cleanup. */ + suspend fun withProfileRemoval( + isOwnerCurrent: () -> Boolean, + operation: suspend (ProfileRemoval) -> T, + ): T { + val removal = ProfileRemoval(isOwnerCurrent) + withLock { + removal.requireCurrent() + synchronized(stateLock) { + checkAvailable() + check(profileRemoval == null) { "Profile removal is already in progress" } + profileRemoval = removal + profileRemovalGeneration++ + } + } + try { + return withContext(removal) { + removal.bind(requireNotNull(currentCoroutineContext()[Job])) + removal.requireCurrent() + operation(removal) + } + } finally { + synchronized(stateLock) { + if (profileRemoval === removal) { + profileRemoval = null + profileRemovalGeneration++ + } + } + removal.drained.complete(Unit) + } + } + + /** Independent publication must never run inside teardown, including its owning context. */ + fun requireNoProfileRemoval() = synchronized(stateLock) { + checkAvailable() + if (profileRemoval != null) throw removalError() + } + + suspend fun requireWalletWipe() { + check(ownsWipe()) { "Wallet wipe admission is required" } + } + + suspend fun requireProfileRemoval() { + val removal = currentCoroutineContext()[ProfileRemoval] + check(removal != null) { "Profile removal admission is required" } + checkRecoveryAccess() + checkProfileRemoval(removal) + } + + suspend fun checkProfileRemoval() { + if (!ownsWipe()) { + checkRecoveryAccess() + checkProfileRemoval(currentCoroutineContext()[ProfileRemoval]) + } + } + + private fun checkProfileRemoval(removal: ProfileRemoval?) { + if (synchronized(stateLock) { profileRemoval !== removal }) { + throw removalError() + } + removal?.requireCurrent() + } + /** * Runs [operation] without the lock but under the wallet wipe admission of [withLock]: it is rejected while a wipe * is in progress. A wipe cannot drain work that does not hold the lock, so a result that a wipe overtakes is @@ -58,12 +151,17 @@ internal class PaykitSdkOperationLock { } suspend fun withWalletWipe(operation: suspend () -> T): T { - synchronized(stateLock) { + check(currentCoroutineContext()[ProfileRemoval] == null) { "Profile removal cannot start a wallet wipe" } + val removal = synchronized(stateLock) { checkAvailable() isWiping = true generation++ + profileRemoval } + removal?.revoke() return try { + // No SDK lock is held while draining the caller's private/local cleanup. + removal?.drained?.await() acquire(Priority.Ordered) try { withContext(WipeContext(this, generation)) { operation() } @@ -75,8 +173,14 @@ internal class PaykitSdkOperationLock { } } - private suspend fun acquire(priority: Priority) { + private suspend fun acquire(priority: Priority, admittedGeneration: Long? = null) { val waiter = synchronized(stateLock) { + // An ordinary caller may have suspended after admission while holding an outer repository mutex. + // Never enqueue it behind the wipe that could now be draining that mutex. + admittedGeneration?.let { + checkAvailable() + if (it != generation) throw wipeError() + } if (!isLocked) { isLocked = true return @@ -137,8 +241,52 @@ internal class PaykitSdkOperationLock { context = "Paykit operation interrupted by wallet wipe", ) + private fun removalError() = PaykitException.Storage( + code = "profile_removal_in_progress", + context = "Paykit operation was interrupted by profile removal", + ) + private class Waiter(val priority: Priority, val ready: CompletableDeferred) + class ProfileRemoval internal constructor( + private val isOwnerCurrent: () -> Boolean, + ) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + + private val lock = Any() + private var revoked = false + private var job: Job? = null + internal val drained = CompletableDeferred() + + internal fun bind(job: Job) { + val shouldCancel = synchronized(lock) { + check(this.job == null) + this.job = job + revoked + } + if (shouldCancel) job.cancel() + } + + internal fun revoke() { + val activeJob = synchronized(lock) { + revoked = true + job + } + activeJob?.cancel() + } + + fun requireCurrent() { + val (activeJob, isRevoked) = synchronized(lock) { job to revoked } + activeJob?.ensureActive() + if (isRevoked || !isOwnerCurrent()) { + throw PaykitException.Storage( + code = "profile_removal_obsolete", + context = "The profile removal no longer owns this session", + ) + } + } + } + private class WipeContext( val owner: PaykitSdkOperationLock, val generation: Long, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 87c452f640..713f3c5111 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -110,6 +110,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network import to.bitkit.BuildConfig import to.bitkit.async.BaseCoroutineScope +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -257,6 +258,7 @@ class PaykitSdkService @Inject constructor( sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, private val settingsStore: SettingsStore, + private val profileRemovalStore: ProfileRemovalStore, ) : BaseCoroutineScope(ioDispatcher, TAG) { private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() @@ -276,12 +278,13 @@ class PaykitSdkService @Inject constructor( private var nextIdentityRepublishAt = 0L private var lastIdentityRepublishAt = 0L private val handleMutex = Mutex() - private val operationLock = PaykitSdkOperationLock() + private val operationLock = PaykitSdkOperationLock { profileRemovalStore.requireAccess() } private val publicReadSlots = PaykitPublicReadSlots(PUBLIC_READ_PERMITS) private val bulkReadPermits = Semaphore(BULK_READ_PERMITS) private val setupMutex = Mutex() private var isSetup = CompletableDeferred() private var setupFailed = false + private var publicRecoverySetup = false private var platformInitializer: () -> Unit = { PaykitAndroid.initializeOrThrow(context) } @Volatile @@ -317,8 +320,9 @@ class PaykitSdkService @Inject constructor( sharedPubky: SharedPubkyClient = SharedPubkyClient(context, ioDispatcher), platformInitializer: (() -> Unit)? = null, settingsStore: SettingsStore, + profileRemovalStore: ProfileRemovalStore = ProfileRemovalStore.inMemoryForTesting(), sdkFactory: () -> PaykitSdk, - ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher, settingsStore) { + ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher, settingsStore, profileRemovalStore) { this.sdkFactory = sdkFactory if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory if (platformInitializer == null) { @@ -332,6 +336,39 @@ class PaykitSdkService @Inject constructor( initialize { initializeRuntime() } } + /** Makes public reads and explicit local recovery available without restoring or publishing saved access. */ + internal suspend fun preparePublicProfileRecovery() { + setupMutex.withLock { + if (isSetup.isCompleted && !setupFailed) return + if (setupFailed) isSetup = CompletableDeferred() + try { + platformInitializer() + setupFailed = false + publicRecoverySetup = true + // Preserve the barrier that callers may already be awaiting on a cold start. + isSetup.complete(Unit) + } catch (error: Throwable) { + setupFailed = true + isSetup.completeExceptionally(error) + throw error + } + } + } + + /** Local credential removal only; it must not initialize a retained session or swallow storage failures. */ + internal suspend fun forgetLocalProfileAccess() { + operationLock.requireProfileRemoval() + operationLock.withLock { + operationLock.requireProfileRemoval() + try { + sessionProvider.clearSessionAccess() + } finally { + resetRuntime() + notifyBackupStateChanged() + } + } + } + fun setPaymentSubmissionActive(active: Boolean) { _isPaymentSubmissionActive.update { active } } @@ -352,17 +389,23 @@ class PaykitSdkService @Inject constructor( private suspend fun initialize(activate: suspend () -> Unit) { setupMutex.withLock { - if (isSetup.isCompleted && !setupFailed) return - if (setupFailed) { + // A cleanup permit authorizes the original operation, never restoration/publication of saved access. + profileRemovalStore.requireNoPending() + if (isSetup.isCompleted && !setupFailed && !publicRecoverySetup) return + val promotingPublicSetup = publicRecoverySetup + if (setupFailed || promotingPublicSetup) { isSetup = CompletableDeferred() setupFailed = false } - try { - platformInitializer() - launch { republishIdentityIfNeeded() } - operationLock.withLock { activate() } + if (!promotingPublicSetup) platformInitializer() + operationLock.withLock { + if (promotingPublicSetup) resetRuntime() + publicRecoverySetup = false + activate() + } isSetup.complete(Unit) + launch { republishIdentityIfNeeded() } } catch (t: Throwable) { setupFailed = true isSetup.completeExceptionally(t) @@ -398,6 +441,8 @@ class PaykitSdkService @Inject constructor( } suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) { + profileRemovalStore.requireNoPending() + operationLock.requireNoProfileRemoval() val publication = launchIdentityRepublish(publicKey, now) withTimeoutOrNull(IDENTITY_REPUBLISH_WAIT_TIMEOUT) { publication.join() @@ -408,12 +453,16 @@ class PaykitSdkService @Inject constructor( private suspend fun launchIdentityRepublish(publicKey: String?, now: Long = nowMillis()): Job { currentCoroutineContext().ensureActive() + val generation = runtimeGeneration return launch { if (!identityRepublishMutex.tryLock()) return@launch identityRepublishJob = currentCoroutineContext().job try { withTimeoutOrNull(IDENTITY_REPUBLISH_TIMEOUT) { runSuspendCatching { + profileRemovalStore.requireNoPending() + operationLock.requireNoProfileRemoval() + if (generation != runtimeGeneration) return@runSuspendCatching if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context) val key = publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret) val identity = key?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching @@ -425,6 +474,9 @@ class PaykitSdkService @Inject constructor( return@runSuspendCatching } + profileRemovalStore.requireNoPending() + operationLock.requireNoProfileRemoval() + if (generation != runtimeGeneration) return@runSuspendCatching republishPublicKey = identity lastIdentityRepublishAt = now nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds @@ -444,8 +496,16 @@ class PaykitSdkService @Inject constructor( /** Rebroadcasts the identity record when one exists. Returns false only when the network reports none. */ suspend fun hasIdentityRecord(publicKey: String): Boolean { + profileRemovalStore.requireNoPending() + operationLock.requireNoProfileRemoval() + val generation = runtimeGeneration isSetup.await() - return bootstrap().republishIdentity(publicKey) + return identityRepublishMutex.withLock { + profileRemovalStore.requireNoPending() + operationLock.requireNoProfileRemoval() + check(generation == runtimeGeneration) { "Pubky runtime changed before identity publication" } + bootstrap().republishIdentity(publicKey) + } } suspend fun currentPublicKey(): String? { @@ -622,7 +682,7 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { withPaykitKey { handle -> val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) - val current = handle.fetchPaykitProfile(publicKey) + val current = completeSdkCall { handle.fetchPaykitProfile(publicKey) } completeSdkCall { handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } } @@ -646,12 +706,66 @@ class PaykitSdkService @Inject constructor( } } + internal suspend fun captureProfileDeletion( + expectedIdentity: String, + isStillCurrent: () -> Boolean, + ): PaykitProfileDeletionSnapshot { + isSetup.await() + return operationLock.withLock { + withPaykitKey { handle -> + check(isStillCurrent()) { "Profile removal identity changed" } + val identity = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { "Profile removal identity changed" } + val profile = completeSdkCall { handle.fetchPaykitProfile(identity) } + val contacts = completeSdkCall { handle.contactRecords() } + check(isStillCurrent()) { "Profile removal identity changed" } + PaykitProfileDeletionSnapshot(identity, profile?.revision, contacts.toList()) + } + } + } + + internal suspend fun validateProfileDeletion(snapshot: PaykitProfileDeletionSnapshot) { + isSetup.await() + operationLock.withLock { + withPaykitKey { handle -> + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, snapshot.publicKey)) { "Profile removal identity changed" } + val profile = completeSdkCall { handle.fetchPaykitProfile(snapshot.publicKey) } + check(profile?.revision == snapshot.profileRevision) { "Profile changed; review it before removing it" } + val contacts = completeSdkCall { handle.contactRecords() } + check( + contacts.toSet() == snapshot.contacts.toSet() + ) { "Contacts changed; review them before removing the profile" } + } + } + } + + internal suspend fun deletePaykitProfile(snapshot: PaykitProfileDeletionSnapshot) { + isSetup.await() + operationLock.withLock { + withPaykitKey { handle -> + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, snapshot.publicKey)) { "Profile removal identity changed" } + if (snapshot.profileRevision == null) { + check(completeSdkCall { handle.fetchPaykitProfile(snapshot.publicKey) } == null) { + "Profile changed; review it before removing it" + } + } else { + completeSdkCall { + handle.deletePaykitProfile(snapshot.profileRevision) + notifyBackupStateChanged() + } + } + } + } + } + suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { withPaykitKey { handle -> val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) - val current = handle.fetchPaykitProfile(publicKey) + val current = completeSdkCall { handle.fetchPaykitProfile(publicKey) } completeSdkCall { current?.let { handle.deletePaykitProfile(it.revision) } notifyBackupStateChanged() @@ -924,7 +1038,8 @@ class PaykitSdkService @Inject constructor( if (updates.isEmpty()) return@withStateRevisionTracking null val publicKey = completeSdkCall { handle.identityStatus() }?.publicKey if (publicKey != null) { - val app = handle.paykitAppRegistry(publicKey)?.apps?.find { it.appId == "bitkit" } + val app = completeSdkCall { handle.paykitAppRegistry(publicKey) } + ?.apps?.find { it.appId == "bitkit" } if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null } for (update in updates) { @@ -1542,6 +1657,20 @@ class PaykitSdkService @Inject constructor( } } + /** The caller retains this admission through its private and local teardown, not only native calls. */ + internal suspend fun withProfileRemoval( + isOwnerCurrent: () -> Boolean, + operation: suspend () -> T, + ): T { + isSetup.await() + // Reserve SDK admission first, so wipe can revoke a removal waiting for publication to drain. + return operationLock.withProfileRemoval(isOwnerCurrent) { + identityRepublishMutex.withLock { operation() } + } + } + + internal suspend fun requireWalletWipe() = operationLock.requireWalletWipe() + suspend fun clearSessionAccess() = operationLock.withLock { clearRegisteredIdentityActivationLocked() } suspend fun forgetSessionAccess() { @@ -1561,14 +1690,20 @@ class PaykitSdkService @Inject constructor( suspend fun withWalletWipe(operation: suspend () -> T): T { // Drain setup before closing admission, so cleanup cannot await setup queued behind its own barrier. runSuspendCatching { initialize() } - .onFailure { Logger.warn("Failed to initialize Paykit before wallet wipe", it, context = TAG) } + .onFailure { + Logger.warn("Failed to initialize Paykit before wallet wipe", it, context = TAG) + preparePublicProfileRecovery() + } return operationLock.withWalletWipe { - resetRuntime() - try { - operation() - } finally { - sessionProvider.clearLiveSessionAccess() + // Removal has drained. Bootstrap publication never waits for the SDK lock. + identityRepublishMutex.withLock { resetRuntime() + try { + operation() + } finally { + sessionProvider.clearLiveSessionAccess() + resetRuntime() + } } } } @@ -1581,22 +1716,27 @@ class PaykitSdkService @Inject constructor( } private suspend fun refreshPaykitKey(force: Boolean = false) { + operationLock.checkProfileRemoval() if (force) { cachedPaykitKey = null cachedBackupState = null } - val root = sessionProvider.loadLocalSecretKey() ?: run { + val root = sessionProvider.loadLocalSecretKey() + operationLock.checkProfileRemoval() + if (root == null) { if (cachedPaykitKey != null) cachedBackupState = null cachedPaykitKey = null return } val publicKey = pubkyPublicKeyFromSecret(root) val savedGeneration = keychain.loadString("${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey")?.toULong() + operationLock.checkProfileRemoval() val cached = cachedPaykitKey if (cached != null && cached.publicKey == publicKey && cached.generation == savedGeneration) return cachedPaykitKey = null cachedBackupState = null val key = paykitKey(root) + operationLock.checkProfileRemoval() sessionProvider.setPaykitIdentitySecretKey(key) cachedPaykitKey = PaykitKeyGeneration(publicKey, key.keyGeneration()) } @@ -1609,9 +1749,10 @@ class PaykitSdkService @Inject constructor( private suspend fun paykitKey(root: PubkyLocalSecretKey): PaykitIdentitySecretKey { val publicKey = pubkyPublicKeyFromSecret(root) - val generation = handle().paykitAppRegistry(publicKey)?.keyGeneration ?: 1uL + val generation = completeSdkCall { handle().paykitAppRegistry(publicKey) }?.keyGeneration ?: 1uL val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey" val saved = keychain.loadString(storageKey)?.toULong() + operationLock.checkProfileRemoval() check(generation >= (saved ?: 1uL)) { "The Paykit App Registry has an older key generation" } if (saved != generation) keychain.upsertString(storageKey, generation.toString()) return root.derivePaykitIdentitySecretKey(generation) @@ -1638,6 +1779,7 @@ class PaykitSdkService @Inject constructor( } resetRuntime() refreshPaykitKey() + operationLock.checkProfileRemoval() val handle = handle() val identityStatus = completeSdkCall { handle.initialize() } if (result.sessionAccess.exportLocalSecretKey() != null) { @@ -1711,6 +1853,7 @@ class PaykitSdkService @Inject constructor( private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { val started = TimeSource.Monotonic.markNow() refreshPaykitKey() + operationLock.checkProfileRemoval() if (BuildConfig.DEBUG) Logger.debug("Prepared Paykit key duration='${started.elapsedNow()}'", context = TAG) return runSuspendCatching { block(handle()) } .onFailure(::invalidatePaykitKeyIfNeeded) @@ -1769,6 +1912,7 @@ class PaykitSdkService @Inject constructor( private suspend fun completeSdkCall(operation: suspend () -> T): T { currentCoroutineContext().ensureActive() + operationLock.checkProfileRemoval() val result = withContext(NonCancellable) { runSuspendCatching { operation() } } .onFailure(::invalidatePaykitKeyIfNeeded) currentCoroutineContext().ensureActive() @@ -1802,7 +1946,7 @@ class PaykitSdkService @Inject constructor( ): T { isSetup.await() return operationLock.withoutLock { - val existing = sdk ?: operationLock.withLock { handle() } + val existing = sdk ?: operationLock.withPublicReadInitialization { handle() } val read: suspend () -> T = { withReadTimeout(timeout) { block(existing) } } when (lane) { PaykitReadLane.Interactive -> publicReadSlots.withSlot(lane, read) @@ -1924,22 +2068,32 @@ internal suspend fun withPaykitBackupStateTracking( onChange() return@withContext } - val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() - val observedSnapshot = runSuspendCatching { readObservedSnapshot() }.getOrNull() - ?.takeIf { it.stateRevision == nextStateRevision } - val nextRevision = observedSnapshot?.backupRevision - ?: runSuspendCatching { readRevision() }.getOrNull() - val stateRevision = observedSnapshot?.stateRevision - ?: runSuspendCatching { readStateRevision() }.getOrNull() - onSnapshot( - if (stateRevision != null && nextRevision != null) { - PaykitBackupStateSnapshot(stateRevision, nextRevision) - } else { - null - }, - ) - if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { - onChange() + var recordedRevision = false + try { + val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val observedSnapshot = runSuspendCatching { readObservedSnapshot() }.getOrNull() + ?.takeIf { it.stateRevision == nextStateRevision } + val nextRevision = observedSnapshot?.backupRevision + ?: runSuspendCatching { readRevision() }.getOrNull() + val stateRevision = observedSnapshot?.stateRevision + ?: runSuspendCatching { readStateRevision() }.getOrNull() + onSnapshot( + if (stateRevision != null && nextRevision != null) { + PaykitBackupStateSnapshot(stateRevision, nextRevision) + } else { + null + }, + ) + if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { + onChange() + } + recordedRevision = true + } finally { + // The mutation completed even when its original caller no longer admits a revision read. + if (!recordedRevision) { + onSnapshot(null) + onChange() + } } } } @@ -2136,3 +2290,10 @@ internal fun paykitStorageCallback(code: String, operation: () -> T): T = if (it is PaykitException) throw it throw PaykitException.Storage(code = code, context = "Platform Paykit storage operation failed") } + +/** The exact remote records presented for one confirmed profile removal. */ +internal data class PaykitProfileDeletionSnapshot( + val publicKey: String, + val profileRevision: String?, + val contacts: List, +) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index e9105a40c9..a9a57d8405 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -29,6 +29,10 @@ class PubkyService @Inject constructor( internal val AUTHORIZATION_TIMEOUT = 30.seconds } + internal suspend fun preparePublicProfileRecovery() = ServiceQueue.CORE.background { + paykitSdkService.preparePublicProfileRecovery() + } + suspend fun initialize() = ServiceQueue.CORE.background { paykitSdkService.initialize() } @@ -54,10 +58,20 @@ class PubkyService @Inject constructor( paykitSdkService.signOut() } + // Keep the initiating Job here, before ServiceQueue replaces it for individual native calls. + internal suspend fun withProfileRemoval(isOwnerCurrent: () -> Boolean, operation: suspend () -> T): T = + paykitSdkService.withProfileRemoval(isOwnerCurrent, operation) + suspend fun forgetSessionAccess() = ServiceQueue.CORE.background { paykitSdkService.forgetSessionAccess() } + internal suspend fun forgetLocalProfileAccess() = ServiceQueue.CORE.background { + paykitSdkService.forgetLocalProfileAccess() + } + + internal suspend fun requireWalletWipe() = paykitSdkService.requireWalletWipe() + suspend fun clearSessionAccess() = ServiceQueue.CORE.background { paykitSdkService.clearSessionAccess() } @@ -207,6 +221,22 @@ class PubkyService @Inject constructor( paykitSdkService.uploadProfileAvatar(bytes, contentType) } + internal suspend fun captureProfileDeletion( + publicKey: String, + isStillCurrent: () -> Boolean, + ): PaykitProfileDeletionSnapshot = ServiceQueue.CORE.background { + paykitSdkService.captureProfileDeletion(publicKey, isStillCurrent) + } + + internal suspend fun validateProfileDeletion(snapshot: PaykitProfileDeletionSnapshot) = + ServiceQueue.CORE.background { + paykitSdkService.validateProfileDeletion(snapshot) + } + + internal suspend fun deletePaykitProfile(snapshot: PaykitProfileDeletionSnapshot) = ServiceQueue.CORE.background { + paykitSdkService.deletePaykitProfile(snapshot) + } + suspend fun deletePaykitProfile() = ServiceQueue.CORE.background { paykitSdkService.deletePaykitProfile() } 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 45563016c3..bb5728a27e 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 @@ -55,14 +55,12 @@ fun EditProfileScreen( viewModel.effects.collect { when (it) { EditProfileEffect.SaveSuccess -> onBackClick() - EditProfileEffect.DeleteSuccess, - EditProfileEffect.DisconnectSuccess, - -> onExitProfile() + is EditProfileEffect.RemovalCompleted -> if (viewModel.isRemovalCurrent(it)) onExitProfile() } } } - Content( + EditProfileContent( uiState = uiState, onBackClick = onBackClick, onNameChange = viewModel::onNameChange, @@ -88,7 +86,7 @@ fun EditProfileScreen( } @Composable -private fun Content( +internal fun EditProfileContent( uiState: EditProfileUiState, onBackClick: () -> Unit, onNameChange: (String) -> Unit, @@ -188,9 +186,19 @@ private fun Content( if (uiState.showDeleteDialog) { AppAlertDialog( - title = stringResource(removal.confirmTitle), - text = stringResource(removal.confirmDescription), - confirmText = stringResource(removal.confirmText), + title = stringResource( + if (uiState.isLocalDisconnect) R.string.profile__sign_out_title else removal.confirmTitle + ), + text = stringResource( + if (uiState.isLocalDisconnect) { + R.string.profile__disconnect_local_description + } else { + removal.confirmDescription + } + ), + confirmText = stringResource( + if (uiState.isLocalDisconnect) R.string.profile__sign_out else removal.confirmText + ), onConfirm = onConfirmDelete, onDismiss = onDismissDeleteDialog, ) @@ -199,11 +207,21 @@ private fun Content( if (uiState.showDeleteFailureDialog) { AppAlertDialog( title = stringResource(removal.errorTitle), - text = stringResource(removal.errorDescription), - confirmText = stringResource(R.string.common__retry), - dismissText = stringResource(R.string.profile__sign_out), - onConfirm = onRetryDelete, - onDismiss = onDisconnectProfile, + text = stringResource( + if (uiState.canReviewRemoval) { + R.string.profile__delete_review_description + } else { + R.string.profile__cleanup_pending + } + ), + confirmText = stringResource( + if (uiState.canReviewRemoval) R.string.profile__review_removal else R.string.profile__sign_out + ), + dismissText = stringResource( + if (uiState.canReviewRemoval) R.string.profile__sign_out else R.string.common__close + ), + onConfirm = if (uiState.canReviewRemoval) onRetryDelete else onDisconnectProfile, + onDismiss = if (uiState.canReviewRemoval) onDisconnectProfile else onDismissDeleteFailureDialog, onDismissRequest = onDismissDeleteFailureDialog, properties = DialogProperties( dismissOnClickOutside = true, @@ -265,7 +283,7 @@ private fun AvatarSection( @Composable private fun Preview() { AppThemeSurface { - Content( + EditProfileContent( uiState = EditProfileUiState( name = "Satoshi", bio = "Authored the Bitcoin white paper", 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 f72026cafe..c99f3aa71f 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 @@ -12,22 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import to.bitkit.R -import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast -import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileOwner import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.usecases.ProfileRemovalConfirmation +import to.bitkit.usecases.ProfileRemovalCoordinator +import to.bitkit.usecases.ProfileRemovalFailure import to.bitkit.utils.Logger import javax.inject.Inject @@ -35,12 +35,17 @@ import javax.inject.Inject class EditProfileViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, - private val privatePaykitRepo: PrivatePaykitRepo, + private val removalCoordinator: ProfileRemovalCoordinator, ) : ViewModel() { companion object { private const val TAG = "EditProfileViewModel" } + private val screenOwner = pubkyRepo.profileOwner() + private var preparationRevision = 0L + private var removalConfirmation: ProfileRemovalConfirmation? = null + private var removalFailure: ProfileRemovalFailure? = null + private val _uiState = MutableStateFlow(EditProfileUiState()) val uiState = _uiState.asStateFlow() @@ -57,6 +62,10 @@ class EditProfileViewModel @Inject constructor( val profile = pubkyRepo.profile.value val publicKey = pubkyRepo.publicKey.value.orEmpty() val removal = if (pubkyRepo.isRingAdoptedIdentity()) ProfileRemoval.UNLINK else ProfileRemoval.DELETE + if (!removalCoordinator.isCurrent(screenOwner)) { + _uiState.update { it.copy(isLoading = false) } + return@launch + } if (profile != null) { _uiState.update { it.copy( @@ -161,12 +170,51 @@ class EditProfileViewModel @Inject constructor( } fun showDeleteConfirmation() { - if (_uiState.value.isBusy) return - _uiState.update { it.copy(showDeleteDialog = true) } + if (_uiState.value.isBusy || _uiState.value.isLoading) return + viewModelScope.launch { + prepareConfirmation { removalCoordinator.prepareDeletion(screenOwner) } + } } fun dismissDeleteDialog() { - _uiState.update { it.copy(showDeleteDialog = false) } + preparationRevision++ + removalConfirmation = null + _uiState.update { + it.copy( + showDeleteDialog = false, + showDeleteFailureDialog = removalFailure != null, + canReviewRemoval = removalFailure?.canReview == true, + ) + } + } + + private suspend fun prepareConfirmation(prepare: suspend () -> Result) { + if (_uiState.value.isBusy) return + val revision = ++preparationRevision + _uiState.update { it.copy(isPreparingRemoval = true) } + try { + prepare().onSuccess { confirmation -> + if (revision != preparationRevision || !removalCoordinator.isCurrent(confirmation.owner)) { + return@onSuccess + } + removalConfirmation = confirmation + _uiState.update { + it.copy( + showDeleteDialog = true, + showDeleteFailureDialog = false, + isLocalDisconnect = confirmation.localOnly + ) + } + }.onFailure { error -> + ToastEventBus.send( + Toast.ToastType.ERROR, + context.getString(R.string.profile__disconnect_error), + error.message + ) + } + } finally { + _uiState.update { it.copy(isPreparingRemoval = false) } + } } fun save() { @@ -217,90 +265,72 @@ class EditProfileViewModel @Inject constructor( } fun deleteProfile() { - viewModelScope.launch { - attemptDeleteProfile() - } + if (_uiState.value.isBusy) return + val confirmation = removalConfirmation ?: return + removalConfirmation = null + viewModelScope.launch { performRemoval(confirmation) } } fun retryDeleteProfile() { - viewModelScope.launch { - attemptDeleteProfile() - } + if (_uiState.value.isBusy) return + val failure = removalFailure ?: return + viewModelScope.launch { prepareConfirmation { removalCoordinator.reviewAgain(failure) } } } fun dismissDeleteFailureDialog() { + preparationRevision++ + removalConfirmation = null _uiState.update { it.copy(showDeleteFailureDialog = false) } } fun disconnectProfile() { + if (_uiState.value.isBusy) return + val failure = removalFailure ?: return viewModelScope.launch { - if (_uiState.value.isBusy) return@launch - _uiState.update { it.copy(showDeleteFailureDialog = false, isDeleting = true) } - try { - val result = runSuspendCatching { - withContext(NonCancellable) { - if (!pubkyRepo.forgetUnrestoredIdentity().getOrThrow()) { - privatePaykitRepo.removePublishedEndpointsForCleanup(TAG).getOrThrow() - pubkyRepo.signOut().getOrThrow() - } - privatePaykitRepo.closeAndClear().getOrThrow() - } - } - if (result.isSuccess) { - _effects.emit(EditProfileEffect.DisconnectSuccess) - } else { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__disconnect_error), - description = result.exceptionOrNull()?.message, - ) - } - } finally { - _uiState.update { it.copy(isDeleting = false) } - } + prepareConfirmation { removalCoordinator.prepareLocalDisconnect(failure) } } } - private suspend fun attemptDeleteProfile() { + private suspend fun performRemoval(confirmation: ProfileRemovalConfirmation) { if (_uiState.value.isBusy) return - _uiState.update { - it.copy( - showDeleteDialog = false, - showDeleteFailureDialog = false, - isDeleting = true, - ) - } + _uiState.update { it.copy(showDeleteDialog = false, showDeleteFailureDialog = false, isDeleting = true) } try { - privatePaykitRepo.beginProfileDeletion() - val result = runSuspendCatching { - privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow() - pubkyRepo.deleteProfile().getOrThrow() - } - if (result.isSuccess) { - privatePaykitRepo.closeAndClear().onFailure { - Logger.error("Failed to clear local state after profile deletion", it, context = TAG) + removalCoordinator.remove(confirmation).onSuccess { endedOwner -> + removalFailure = null + if (removalCoordinator.isCurrent(endedOwner)) { + _effects.emit(EditProfileEffect.RemovalCompleted(endedOwner)) + } + }.onFailure { error -> + removalFailure = (error as? ProfileRemovalFailure) ?: removalFailure + if (removalFailure != null) { + _uiState.update { + it.copy( + showDeleteFailureDialog = true, + canReviewRemoval = requireNotNull(removalFailure).canReview, + ) + } + } else { ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = it.message, + Toast.ToastType.ERROR, + context.getString(R.string.profile__disconnect_error), + error.message, ) - return } - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(_uiState.value.removal.successToast), - ) - _effects.emit(EditProfileEffect.DeleteSuccess) - } else { - val error = requireNotNull(result.exceptionOrNull()) { "Profile delete failed without an error" } - Logger.error("Failed to delete profile", error, context = TAG) - _uiState.update { it.copy(showDeleteFailureDialog = true) } } } finally { - withContext(NonCancellable) { privatePaykitRepo.endProfileDeletion() } - _uiState.update { it.copy(isDeleting = false) } + removalFailure = removalCoordinator.failureFor(confirmation) ?: removalFailure + _uiState.update { + it.copy( + isDeleting = false, + showDeleteFailureDialog = removalFailure != null, + canReviewRemoval = removalFailure?.canReview == true, + ) + } } } + + fun isRemovalCurrent(effect: EditProfileEffect.RemovalCompleted): Boolean = + removalCoordinator.isCurrent(effect.owner) } @Stable @@ -317,12 +347,15 @@ data class EditProfileUiState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isDeleting: Boolean = false, + val isPreparingRemoval: Boolean = false, + val isLocalDisconnect: Boolean = false, + val canReviewRemoval: Boolean = true, val showDeleteDialog: Boolean = false, val showDeleteFailureDialog: Boolean = false, val showAddLinkSheet: Boolean = false, val showAddTagSheet: Boolean = false, ) { - val isBusy: Boolean get() = isSaving || isDeleting + val isBusy: Boolean get() = isSaving || isDeleting || isPreparingRemoval } enum class ProfileRemoval( @@ -362,6 +395,5 @@ enum class ProfileRemoval( sealed interface EditProfileEffect { data object SaveSuccess : EditProfileEffect - data object DeleteSuccess : EditProfileEffect - data object DisconnectSuccess : EditProfileEffect + data class RemovalCompleted(val owner: PubkyProfileOwner) : 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 5958a0adab..f46736a047 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 @@ -87,12 +87,12 @@ fun ProfileScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - ProfileEffect.SignedOut -> onBackClick() + is ProfileEffect.SignedOut -> if (viewModel.isRemovalCurrent(it)) onBackClick() } } } - Content( + ProfileContent( uiState = uiState, onBackClick = onBackClick, onClickEdit = onEditProfile, @@ -111,7 +111,7 @@ fun ProfileScreen( } @Composable -private fun Content( +internal fun ProfileContent( uiState: ProfileUiState, onBackClick: () -> Unit, onClickEdit: () -> Unit, @@ -139,6 +139,20 @@ private fun Content( } when { + uiState.recoveryPending -> ScreenColumn { + topBar(Modifier) + VerticalSpacer(32.dp) + BodyM(text = stringResource(R.string.profile__cleanup_pending)) + VerticalSpacer(24.dp) + PrimaryButton( + text = stringResource(R.string.profile__sign_out), + onClick = onClickSignOut, + enabled = !uiState.isSigningOut, + modifier = Modifier.testTag("ProfileRecoveryDisconnect"), + ) + if (uiState.isSigningOut) GradientCircularProgressIndicator(Modifier.size(32.dp)) + } + currentProfile != null -> Box(modifier = Modifier.screen()) { ProfileBody( profile = currentProfile, @@ -178,7 +192,13 @@ private fun Content( if (uiState.showSignOutDialog) { AppAlertDialog( title = stringResource(R.string.profile__sign_out_title), - text = stringResource(R.string.profile__sign_out_description), + text = stringResource( + if (uiState.localDisconnect) { + R.string.profile__disconnect_local_description + } else { + R.string.profile__sign_out_description + } + ), confirmText = stringResource(R.string.profile__sign_out), onConfirm = onConfirmSignOut, onDismiss = onDismissSignOutDialog, @@ -461,7 +481,7 @@ private fun EmptyState( @Composable private fun PreviewCached() { AppThemeSurface { - Content( + ProfileContent( uiState = ProfileUiState( cachedProfile = PubkyCachedProfile( publicKey = "pk8e3qm5...gxag", @@ -491,7 +511,7 @@ private fun PreviewCached() { @Composable private fun Preview() { AppThemeSurface { - Content( + ProfileContent( uiState = ProfileUiState( profile = PubkyProfile( publicKey = "pk8e3qm5...gxag", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index 4880ed4375..abb6c6af76 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,16 +21,17 @@ 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.R import to.bitkit.data.PubkyCachedProfile -import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast -import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileOwner import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.usecases.ProfileRemovalConfirmation +import to.bitkit.usecases.ProfileRemovalCoordinator +import to.bitkit.usecases.ProfileRemovalFailure import to.bitkit.utils.Logger import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -40,13 +40,19 @@ import kotlin.time.Duration.Companion.seconds class ProfileViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, - private val privatePaykitRepo: PrivatePaykitRepo, + private val removalCoordinator: ProfileRemovalCoordinator, ) : ViewModel() { companion object { private const val TAG = "ProfileViewModel" private val COPIED_POPUP_DURATION = 3.seconds } + private var displayedOwner = pubkyRepo.profileOwner() + private var ownerBoundToSession = displayedOwner.signIn != null + private var removalConfirmation: ProfileRemovalConfirmation? = null + private var removalFailure: ProfileRemovalFailure? = null + private var preparationRevision = 0L + private val _localDisconnect = MutableStateFlow(false) private val _showSignOutDialog = MutableStateFlow(false) private val _isSigningOut = MutableStateFlow(false) private val _showAddTagSheet = MutableStateFlow(false) @@ -63,13 +69,23 @@ class ProfileViewModel @Inject constructor( pubkyRepo.isRestoringSession, _isRefreshing, ) { loading, restoring, refreshing -> loading || restoring || refreshing } - private val controls = combine( + private val dialogControls = combine( _showSignOutDialog, _isSigningOut, _showAddTagSheet, _copiedPublicKey, ) { showSignOutDialog, isSigningOut, showAddTagSheet, copiedPublicKey -> - ProfileControls(showSignOutDialog, isSigningOut, showAddTagSheet, copiedPublicKey) + ProfileControls( + showSignOutDialog = showSignOutDialog, + isSigningOut = isSigningOut, + showAddTagSheet = showAddTagSheet, + copiedPublicKey = copiedPublicKey + ) + } + + private val controls = combine(dialogControls, pubkyRepo.profileRecoveryPending, _localDisconnect) { + controls, pending, localOnly -> + controls.copy(recoveryPending = pending, localDisconnect = localOnly) } init { @@ -92,7 +108,7 @@ class ProfileViewModel @Inject constructor( publicKey = pubkyRepo.publicKey.value, isLoading = pubkyRepo.isLoadingProfile.value || pubkyRepo.isRestoringSession.value || _isRefreshing.value, cachedProfile = pubkyRepo.cachedProfile.value, - controls = ProfileControls(), + controls = ProfileControls(recoveryPending = pubkyRepo.profileRecoveryPending.value), ), ) @@ -122,7 +138,7 @@ class ProfileViewModel @Inject constructor( profileLoadJob = viewModelScope.launch { _isRefreshing.update { true } try { - load() + if (pubkyRepo.profileRecoveryPending.first { it != null } == false) load() } finally { _isRefreshing.update { false } } @@ -130,11 +146,36 @@ class ProfileViewModel @Inject constructor( } fun showSignOutConfirmation() { - _showSignOutDialog.update { true } + if (_isSigningOut.value) return + val owner = uiState.value.owner ?: return + val revision = ++preparationRevision + _isSigningOut.value = true + viewModelScope.launch { + try { + val failure = removalFailure + val result = if (failure != null) { + removalCoordinator.prepareLocalDisconnect(failure) + } else { + removalCoordinator.prepareSignOut(owner) + } + result.onSuccess { confirmation -> + if (revision != preparationRevision || !removalCoordinator.isCurrent(confirmation.owner)) { + return@onSuccess + } + removalConfirmation = confirmation + _localDisconnect.value = confirmation.localOnly + _showSignOutDialog.value = true + }.onFailure { showRemovalError(it) } + } finally { + _isSigningOut.value = false + } + } } fun dismissSignOutDialog() { - _showSignOutDialog.update { false } + preparationRevision++ + removalConfirmation = null + _showSignOutDialog.value = false } fun showAddTagSheet() { @@ -157,35 +198,37 @@ class ProfileViewModel @Inject constructor( } fun signOut() { + if (_isSigningOut.value) return + val confirmation = removalConfirmation ?: return + removalConfirmation = null + _isSigningOut.value = true + _showSignOutDialog.value = false viewModelScope.launch { - if (_isSigningOut.value) return@launch - _isSigningOut.update { true } - _showSignOutDialog.update { false } try { - val result = runSuspendCatching { - withContext(NonCancellable) { - if (!pubkyRepo.forgetUnrestoredIdentity().getOrThrow()) { - privatePaykitRepo.removePublishedEndpointsForCleanup(TAG).getOrThrow() - pubkyRepo.signOut().getOrThrow() - } - privatePaykitRepo.closeAndClear() - } - } - if (result.isSuccess) { - _effects.emit(ProfileEffect.SignedOut) - } else { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__sign_out_title), - description = result.exceptionOrNull()?.message, - ) + removalCoordinator.remove(confirmation).onSuccess { endedOwner -> + removalFailure = null + if (removalCoordinator.isCurrent(endedOwner)) _effects.emit(ProfileEffect.SignedOut(endedOwner)) + }.onFailure { + removalFailure = (it as? ProfileRemovalFailure) ?: removalFailure + showRemovalError(it) } } finally { - _isSigningOut.update { false } + removalFailure = removalCoordinator.failureFor(confirmation) ?: removalFailure + _isSigningOut.value = false } } } + fun isRemovalCurrent(effect: ProfileEffect.SignedOut): Boolean = removalCoordinator.isCurrent(effect.owner) + + private suspend fun showRemovalError(error: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__sign_out_title), + description = error.message, + ) + } + fun copyPublicKey() { val pk = pubkyRepo.publicKey.value ?: pubkyRepo.readOnlyProfile.value?.publicKey ?: return context.setClipboardText(pk, context.getString(R.string.profile__public_key)) @@ -208,17 +251,32 @@ class ProfileViewModel @Inject constructor( isLoading: Boolean, cachedProfile: PubkyCachedProfile?, controls: ProfileControls, - ) = ProfileUiState( - profile = profile, - cachedProfile = cachedProfile?.takeIf { isLoading && it.publicKey == publicKey }, - publicKey = publicKey ?: profile?.publicKey, - canEdit = publicKey != null && publicKey == pubkyRepo.profile.value?.publicKey, - isLoading = isLoading, - showSignOutDialog = controls.showSignOutDialog, - isSigningOut = controls.isSigningOut, - showAddTagSheet = controls.showAddTagSheet, - copiedPublicKey = controls.copiedPublicKey, - ) + ): ProfileUiState { + if (!ownerBoundToSession) { + val restoredOwner = pubkyRepo.profileOwner() + if (restoredOwner.storedRevision == displayedOwner.storedRevision && restoredOwner.signIn != null) { + displayedOwner = restoredOwner + ownerBoundToSession = true + } + } + return ProfileUiState( + profile = profile?.takeIf { controls.recoveryPending == false }, + cachedProfile = cachedProfile?.takeIf { + controls.recoveryPending == false && isLoading && it.publicKey == publicKey + }, + publicKey = publicKey ?: profile?.publicKey, + canEdit = controls.recoveryPending == false && publicKey != null && + publicKey == pubkyRepo.profile.value?.publicKey, + isLoading = controls.recoveryPending == null || (controls.recoveryPending == false && isLoading), + recoveryPending = controls.recoveryPending == true, + localDisconnect = controls.localDisconnect, + owner = displayedOwner, + showSignOutDialog = controls.showSignOutDialog, + isSigningOut = controls.isSigningOut, + showAddTagSheet = controls.showAddTagSheet, + copiedPublicKey = controls.copiedPublicKey, + ) + } private fun updateTags( transform: (List) -> List, @@ -226,6 +284,7 @@ class ProfileViewModel @Inject constructor( ) { viewModelScope.launch { tagUpdateMutex.withLock { + if (pubkyRepo.profileRecoveryPending.value != false) return@withLock val profile = pubkyRepo.profile.value ?: return@withLock val tags = transform(profile.tags) if (tags == profile.tags) { @@ -260,6 +319,9 @@ data class ProfileUiState( val cachedProfile: PubkyCachedProfile? = null, val publicKey: String? = null, val canEdit: Boolean = false, + val recoveryPending: Boolean = false, + val localDisconnect: Boolean = false, + val owner: PubkyProfileOwner? = null, val isLoading: Boolean = true, val showSignOutDialog: Boolean = false, val isSigningOut: Boolean = false, @@ -268,6 +330,8 @@ data class ProfileUiState( ) private data class ProfileControls( + val recoveryPending: Boolean? = null, + val localDisconnect: Boolean = false, val showSignOutDialog: Boolean = false, val isSigningOut: Boolean = false, val showAddTagSheet: Boolean = false, @@ -275,5 +339,5 @@ private data class ProfileControls( ) sealed interface ProfileEffect { - data object SignedOut : ProfileEffect + data class SignedOut(val owner: PubkyProfileOwner) : ProfileEffect } diff --git a/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt b/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt new file mode 100644 index 0000000000..a6c1757934 --- /dev/null +++ b/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt @@ -0,0 +1,169 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import to.bitkit.data.ProfileRemovalStore +import to.bitkit.ext.runSuspendCatching +import to.bitkit.repositories.ContactPaymentSettingsRepo +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileDeletion +import to.bitkit.repositories.PubkyProfileOwner +import to.bitkit.repositories.PubkyRepo +import to.bitkit.utils.AppError +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +/** One confirmation belongs to one identity and may start destructive work at most once. */ +class ProfileRemovalConfirmation internal constructor( + internal val owner: PubkyProfileOwner, + internal val attemptId: String, + internal val deletion: PubkyProfileDeletion?, + internal val localOnly: Boolean, + internal val reviewCeiling: AtomicReference = AtomicReference(deletion), +) { + internal val used = AtomicBoolean(false) + internal val failure = AtomicReference(null) +} + +class ProfileRemovalFailure internal constructor( + cause: Throwable, + internal val confirmation: ProfileRemovalConfirmation, + internal val recoveryOwner: PubkyProfileOwner, + internal val localCompletionStarted: Boolean, +) : AppError("Profile removal did not finish. Review its state or disconnect locally.", cause) { + internal val canReview: Boolean get() = confirmation.deletion != null && !localCompletionStarted +} + +/** Remote work never resumes automatically from the durable local cleanup obligation. */ +@Singleton +class ProfileRemovalCoordinator @Inject constructor( + private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: PrivatePaykitRepo, + private val sharingRepo: ContactPaymentSettingsRepo, + private val removalStore: ProfileRemovalStore, +) { + suspend fun prepareDeletion(owner: PubkyProfileOwner): Result = runSuspendCatching { + removalStore.requireNoPending() + check(pubkyRepo.isProfileOwnerCurrent(owner)) { "The profile identity changed" } + val deletion = pubkyRepo.prepareProfileDeletion(owner).getOrThrow() + removalStore.requireNoPending() + check(pubkyRepo.isProfileOwnerCurrent(owner)) { "The profile identity changed" } + ProfileRemovalConfirmation(owner, UUID.randomUUID().toString(), deletion, localOnly = false) + } + + suspend fun prepareSignOut(owner: PubkyProfileOwner): Result = runSuspendCatching { + check(pubkyRepo.isProfileOwnerCurrent(owner)) { "The profile identity changed" } + val pending = removalStore.pendingAttempt() + check(pubkyRepo.isProfileOwnerCurrent(owner)) { "The profile identity changed" } + ProfileRemovalConfirmation( + owner, + pending ?: UUID.randomUUID().toString(), + deletion = null, + localOnly = pending != null || owner.signIn == null, + ) + } + + /** Re-read the original identity's remote state, then require a new user confirmation. */ + suspend fun reviewAgain(failure: ProfileRemovalFailure): Result = runSuspendCatching { + check(!failure.localCompletionStarted) { "Finish disconnecting this profile locally" } + val previous = failure.confirmation + check(previous.deletion != null) { "Disconnect this profile locally to finish cleanup" } + var deletion: PubkyProfileDeletion? = null + pubkyRepo.performProfileRemoval(previous.owner, previous.attemptId) { + val fresh = pubkyRepo.prepareProfileDeletion(previous.owner).getOrThrow() + val confirmed = checkNotNull(previous.reviewCeiling.get()).snapshot + val remaining = fresh.snapshot + check(remaining.publicKey == confirmed.publicKey) { "The profile identity changed" } + check(remaining.profileRevision == null || remaining.profileRevision == confirmed.profileRevision) { + "The profile changed after confirmation. Disconnect locally to finish cleanup." + } + check(confirmed.contacts.containsAll(remaining.contacts)) { + "Contacts changed after confirmation. Disconnect locally to finish cleanup." + } + // Missing records leave this attempt's scope; later reviews cannot add them back. + previous.reviewCeiling.set(fresh) + deletion = fresh + }.getOrThrow() + ProfileRemovalConfirmation( + previous.owner, + previous.attemptId, + deletion, + previous.localOnly, + previous.reviewCeiling + ) + } + + suspend fun prepareLocalDisconnect(failure: ProfileRemovalFailure): Result = + runSuspendCatching { + val pending = removalStore.pendingAttempt() + check(pending == null || pending == failure.confirmation.attemptId) { + "The pending profile cleanup changed" + } + check(pubkyRepo.isProfileOwnerCurrent(failure.recoveryOwner)) { "The profile identity changed" } + ProfileRemovalConfirmation( + failure.recoveryOwner, + failure.confirmation.attemptId, + deletion = null, + localOnly = true, + reviewCeiling = failure.confirmation.reviewCeiling, + ) + } + + suspend fun remove(confirmation: ProfileRemovalConfirmation): Result = runSuspendCatching { + check(confirmation.used.compareAndSet(false, true)) { "Review this profile removal before trying again" } + sharingRepo.withProfileRemoval { + pubkyRepo.performProfileRemoval(confirmation.owner, confirmation.attemptId) { + var localCompletionStarted = false + try { + check(confirmation.deletion == null || confirmation.reviewCeiling.get() === confirmation.deletion) { + "This confirmation was replaced by a later review" + } + privatePaykitRepo.withProfileDeletionPaused { pause -> + confirmation.deletion?.let { pubkyRepo.validateProfileDeletion(it) } + removalStore.begin(confirmation.attemptId) + if (!confirmation.localOnly) { + privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow() + pubkyRepo.removeBitkitPaymentEndpoints().getOrThrow() + confirmation.deletion?.let { pubkyRepo.deleteProfile(it).getOrThrow() } + pubkyRepo.signOutForProfileRemoval() + } + localCompletionStarted = true + pubkyRepo.finishProfileRemovalLocally() + withContext(NonCancellable) { + privatePaykitRepo.finishProfileRemoval(pause).getOrThrow() + removalStore.complete(confirmation.attemptId) + } + } + } catch (error: CancellationException) { + captureFailure(error, confirmation, localCompletionStarted) + throw error + } catch (error: Throwable) { + throw captureFailure(error, confirmation, localCompletionStarted) + } + }.getOrThrow() + } + } + + private fun captureFailure( + error: Throwable, + confirmation: ProfileRemovalConfirmation, + localCompletionStarted: Boolean, + ): ProfileRemovalFailure { + // Admission still excludes replacements, even when our own cleanup has ended the sign-in. + val failure = ProfileRemovalFailure(error, confirmation, pubkyRepo.profileOwner(), localCompletionStarted) + confirmation.failure.set(failure) + return failure + } + + fun failureFor(confirmation: ProfileRemovalConfirmation): ProfileRemovalFailure? = confirmation.failure.get() + + fun isCurrent(owner: PubkyProfileOwner): Boolean = pubkyRepo.isProfileOwnerCurrent(owner) + + private companion object { + const val TAG = "ProfileRemovalCoordinator" + } +} diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 96307eaaf4..4262c8933c 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -4,6 +4,7 @@ import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.sync.Mutex import to.bitkit.data.AppDb import to.bitkit.data.CacheStore +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain @@ -11,6 +12,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaykitUsdtPaymentRepo @@ -24,6 +26,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.util.UUID import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -50,6 +53,8 @@ class WipeWalletUseCase @Inject constructor( private val paykitSdkService: PaykitSdkService, private val usdtRepo: UsdtRepo, private val paykitUsdtPaymentRepo: PaykitUsdtPaymentRepo, + private val profileRemovalStore: ProfileRemovalStore, + private val contactPaymentSettingsRepo: Provider, ) { private val wipeMutex = Mutex() @@ -63,11 +68,22 @@ class WipeWalletUseCase @Inject constructor( lightningRepo.setWiping(true) val result = try { runSuspendCatching { - paykitSdkService.withWalletWipe { - stopNode().getOrThrow() - cleanupRemote() - wipeLocal(walletIndex, resetWalletState).getOrThrow() - onSuccess() + contactPaymentSettingsRepo.get().withWalletWipe(pubkyRepo::withWalletWipeAdmission) { + paykitSdkService.withWalletWipe { + val pending = profileRemovalStore.pendingAttempt() + val attemptId = pending ?: UUID.randomUUID().toString() + profileRemovalStore.withAccess(attemptId) { + privatePaykitRepo.get().withProfileDeletionPaused { pause -> + stopNode().getOrThrow() + profileRemovalStore.begin(attemptId) + if (pending == null) cleanupRemote() + wipeLocal(walletIndex, pause).getOrThrow() + profileRemovalStore.complete(attemptId) + resetWalletState() + onSuccess() + } + } + } } } } finally { @@ -91,30 +107,34 @@ class WipeWalletUseCase @Inject constructor( private suspend fun cleanupRemote() { step("remove Paykit published endpoints") { privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) } step("remove Bitkit payment endpoints") { pubkyRepo.removeBitkitPaymentEndpoints() } - step("close Paykit SDK") { privatePaykitRepo.get().closeAndClear() } } - private suspend fun wipeLocal(walletIndex: Int, resetWalletState: () -> Unit): Result { + private suspend fun wipeLocal( + walletIndex: Int, + pause: PrivatePaykitRepo.ProfileDeletionPause, + ): Result { lightningRepo.wipeStorage(walletIndex).onFailure { return Result.failure(it) } step("clear post-migration sync flag") { migrationService.setNeedsPostMigrationSync(false) } step("clear migration data") { migrationService.cleanupAfterMigration() } - step("clear Paykit address reservations") { privatePaykitAddressReservationRepo.clear() } - step("wipe Pubky local state") { pubkyRepo.wipeLocalState() } - val keychainWiped = step("wipe USDT and keychain") { usdtRepo.wipe { keychain.wipe() } } - if (keychainWiped) paykitUsdtPaymentRepo.resetForWipe() + runSuspendCatching { usdtRepo.wipe { keychain.wipe() }.getOrThrow() } + .getOrElse { return Result.failure(WipeIncomplete(it)) } + paykitUsdtPaymentRepo.resetForWipe() + // Consumption watermarks survive until the keychain wipe is acknowledged. Pending remains on any failure. + privatePaykitRepo.get().finishProfileRemoval(pause).getOrElse { return Result.failure(it) } + pubkyRepo.finishProfileWipeLocally() + privatePaykitAddressReservationRepo.clear() step("delete FCM token") { firebaseMessaging.deleteToken() } step("wipe core data") { coreService.wipeData() } step("clear database") { db.clearAllTables() } - step("reset settings") { settingsStore.reset() } + settingsStore.reset() step("reset cache") { cacheStore.reset() } step("clear watch-only accounts") { watchOnlyAccountRepo.clear() } step("reset widgets") { widgetsStore.reset() } blocktankRepo.resetState() activityRepo.resetState() hwWalletRepo.resetState() - resetWalletState() step("mark migration checked") { migrationService.markMigrationChecked() } - return if (keychainWiped) Result.success(Unit) else Result.failure(WipeIncomplete()) + return Result.success(Unit) } private suspend fun step(name: String, block: suspend () -> Any?): Boolean = @@ -130,4 +150,4 @@ class WipeWalletUseCase @Inject constructor( class WipeAlreadyInProgress : AppError("Wallet wipe already in progress") -class WipeIncomplete : AppError("Wallet wipe did not complete, please reset again") +class WipeIncomplete(cause: Throwable? = null) : AppError("Wallet wipe did not complete, please reset again", cause) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ef470a7fd4..6230654695 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3415,7 +3415,7 @@ class AppViewModel @Inject constructor( val timeout = if (isContactLink) PubkyService.AUTHORIZATION_TIMEOUT else PUBKY_AUTH_DEEPLINK_TIMEOUT val isInitializationReady = withTimeoutOrNull(timeout) { pubkyRepo.awaitInitialization() - if (!isContactLink) awaitPubkyAuthIdentity() + if (!isContactLink && !awaitPubkyAuthIdentity()) return@withTimeoutOrNull false awaitContactDataForDeeplink(isContactLink) } ?: false if (!isInitializationReady) { @@ -3444,19 +3444,28 @@ class AppViewModel @Inject constructor( return isPaykitUiEnabledFromSettings() && walletRepo.walletExists() } - private suspend fun awaitPubkyAuthIdentity() { - while (pubkyRepo.awaitIdentityReady() == PubkyIdentityReadiness.Unavailable) { - if (isOnline.value == ConnectivityState.CONNECTED && pubkyRepo.localIdentityPublicKey() != null) { - Logger.debug("Continuing sign-in link with the stored Pubky key", context = TAG) - return + private suspend fun awaitPubkyAuthIdentity(): Boolean { + while (true) { + when (pubkyRepo.awaitIdentityReady()) { + PubkyIdentityReadiness.RecoveryRequired -> return false + PubkyIdentityReadiness.Ready, PubkyIdentityReadiness.Missing -> return true + PubkyIdentityReadiness.Unavailable -> { + if (pubkyRepo.requiresProfileRecovery()) return false + if (isOnline.value == ConnectivityState.CONNECTED && pubkyRepo.localIdentityPublicKey() != null) { + return !pubkyRepo.requiresProfileRecovery() + } + delay(PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL) + // A pending local cleanup must end this wait even while connectivity stays offline. + combine(isOnline, pubkyRepo.profileRecoveryPending) { online, pending -> + online == ConnectivityState.CONNECTED || pending == true + }.first { it } + } } - delay(PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL) - isOnline.first { it == ConnectivityState.CONNECTED } - Logger.debug("Retrying Pubky session restore for a sign-in link", context = TAG) } } private suspend fun showPubkyAuthWaitToastIfRestoring() { + if (pubkyRepo.requiresProfileRecovery()) return if (pubkyRepo.publicKey.value != null) return if (!isPaykitUiEnabledFromSettings() || !walletRepo.walletExists()) return if (!runSuspendCatching { pubkyRepo.hasIdentity() }.getOrDefault(false)) return @@ -7060,6 +7069,11 @@ class AppViewModel @Inject constructor( } // A stale amount above inbound liquidity would hide the Auto tab until the receive state refreshes if (sheetType is Sheet.Receive) walletRepo.setBip21AmountSats(null) + if (sheetType is Sheet.PubkyAuth && pubkyRepo.requiresProfileRecovery()) { + hidePubkyAuthWaitToast() + showPubkyIdentityUnavailableToast() + return@launch + } _currentSheet.update { sheetType } } sheetTransitionJob = nextJob @@ -8101,6 +8115,7 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { + if (pubkyRepo.requiresProfileRecovery()) return showPubkyIdentityUnavailableToast() val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) val createsIdentity = isSignup && ( @@ -8109,12 +8124,14 @@ class AppViewModel @Inject constructor( ) if (createsIdentity) { if (rejectPubkySignupForExistingIdentity()) return + if (pubkyRepo.requiresProfileRecovery()) return showPubkyIdentityUnavailableToast() showSheet(Sheet.PubkyAuth(authUrl)) return } if (pubkyRepo.publicKey.value == null) { if (pubkyRepo.localIdentityPublicKey() == null) return showPubkyIdentityUnavailableToast() + if (pubkyRepo.requiresProfileRecovery()) return showPubkyIdentityUnavailableToast() showSheet(Sheet.PubkyAuth(authUrl)) return } @@ -8127,10 +8144,20 @@ class AppViewModel @Inject constructor( ) return } + if (pubkyRepo.requiresProfileRecovery()) return showPubkyIdentityUnavailableToast() showSheet(Sheet.PubkyAuth(authUrl)) } private suspend fun showPubkyIdentityUnavailableToast() { + if (pubkyRepo.requiresProfileRecovery()) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__nav_title), + description = context.getString(R.string.profile__cleanup_pending), + visibilityTime = Toast.VISIBILITY_TIME_LONG, + ) + return + } val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrDefault(true) if (hasIdentity) { ToastEventBus.send( diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 23f52e9523..36ca05763b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -435,9 +435,13 @@ إضافة علامة التفاصيل حسنًا + لم يكتمل تنظيف الملف الشخصي. افصل الاتصال محليًا لإزالة وصول هذا الجهاز دون إعادة محاولة الحذف عن بُعد. إنشاء ملف شخصي + قد تكون بعض التغييرات قد اكتملت. راجع الحالة الحالية قبل التأكيد مجددًا، أو افصل الاتصال محليًا. + أزل وصول هذا الجهاز وأكمل التنظيف المحلي. لن يؤدي هذا إلى حذف الملف الشخصي من Pubky. الملف الشخصي حاول مرة أخرى + مراجعة اقتراحات نسخ احتياطي الآن بعد أن لديك بعض الأموال في محفظتك، حان وقت عمل نسخة احتياطية! diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 854b902b00..8a7fe9ce1b 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -447,10 +447,14 @@ Autorizar Detalles Autorizar + La limpieza del perfil está incompleta. Desconéctate localmente para retirar el acceso de este dispositivo sin volver a intentar la eliminación remota. Crear perfil + Es posible que algunos cambios se hayan completado. Revisa el estado actual antes de confirmar de nuevo o desconéctate localmente. + Retira el acceso de este dispositivo y completa la limpieza local. Esto no elimina el perfil de Pubky. Editar Perfil Perfil Inténtelo de nuevo + Revisar Sugerencias Sugerencias para añadir Sí, desvincular diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index bfcc033901..c1f4679a8e 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -441,10 +441,14 @@ Nota: Qualsevol enllaç que afegiu serà visible públicament. Afegeix etiqueta Detalls + La neteja del perfil és incompleta. Desconnecta localment per eliminar l’accés d’aquest dispositiu sense tornar a intentar la supressió remota. Crear perfil + És possible que alguns canvis s’hagin completat. Revisa l’estat actual abans de tornar a confirmar, o desconnecta localment. + Elimina l’accés d’aquest dispositiu i completa la neteja local. Això no suprimeix el perfil de Pubky. Editar perfil Perfil Torna a intentar + Revisa Suggeriments Sí, desvincula Això eliminarà les teves dades de Paykit i desvincularà aquest pubky de Bitkit. Continuarà disponible a Pubky Ring. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ec26262d2b..fac03ac310 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -446,10 +446,14 @@ Autorizovat Podrobnosti Autorizovat + Vyčištění profilu není dokončeno. Odpojte se místně a odeberte přístup tohoto zařízení bez dalšího pokusu o vzdálené smazání. Vytvořit profil + Některé změny již mohly být dokončeny. Před dalším potvrzením zkontrolujte aktuální stav nebo se odpojte místně. + Odeberte přístup tohoto zařízení a dokončete místní vyčištění. Tím se profil z Pubky nesmaže. Upravit profil Profil Zkusit znovu + Zkontrolovat Návrhy Ano, zrušit propojení Tímto smažete svá data Paykit a zrušíte propojení tohoto pubky s Bitkitem. V Pubky Ring zůstane k dispozici. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 862f4f1b0c..4a93e1f5b4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -427,10 +427,14 @@ Tag hinzufügen Autorisieren Sie Autorisieren Sie + Die Profilbereinigung ist unvollständig. Trenne die Verbindung lokal, um den Zugriff dieses Geräts zu entfernen, ohne die entfernte Löschung erneut zu versuchen. Profil erstellen + Einige Änderungen wurden möglicherweise abgeschlossen. Prüfe den aktuellen Stand vor einer erneuten Bestätigung oder trenne die Verbindung lokal. + Entferne den Zugriff dieses Geräts und schließe die lokale Bereinigung ab. Dadurch wird das Profil nicht aus Pubky gelöscht. Profil bearbeiten Profil Erneut versuchen + Prüfen Vorschläge Ja, aufheben Dadurch werden deine Paykit-Daten gelöscht und die Verknüpfung dieses pubky mit Bitkit aufgehoben. In Pubky Ring bleibt er weiterhin verfügbar. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 273e174438..f5bc116b80 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -436,9 +436,13 @@ Προσθήκη ετικέτας Λεπτομέρειες ΟΚ + Η εκκαθάριση του προφίλ δεν ολοκληρώθηκε. Αποσυνδεθείτε τοπικά για να καταργήσετε την πρόσβαση αυτής της συσκευής χωρίς νέα προσπάθεια απομακρυσμένης διαγραφής. Δημιουργία προφίλ + Ορισμένες αλλαγές ενδέχεται να έχουν ολοκληρωθεί. Ελέγξτε την τρέχουσα κατάσταση πριν επιβεβαιώσετε ξανά ή αποσυνδεθείτε τοπικά. + Καταργήστε την πρόσβαση αυτής της συσκευής και ολοκληρώστε την τοπική εκκαθάριση. Αυτό δεν διαγράφει το προφίλ από το Pubky. Προφίλ Προσπαθήστε Ξανά + Έλεγχος Προτάσεις Αντίγραφο ασφαλείας Τώρα που έχεις κεφάλαια στο πορτοφόλι σου, ήρθε η ώρα να δημιουργήσεις αντίγραφο ασφαλείας των χρημάτων σου! diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 558a020922..610788ed11 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -444,10 +444,14 @@ Nota: Cualquier enlace que añada será públicamente visible. Añadir etiqueta Detalles + La limpieza del perfil está incompleta. Desconéctate localmente para retirar el acceso de este dispositivo sin volver a intentar la eliminación remota. Crear perfil + Es posible que algunos cambios se hayan completado. Revisa el estado actual antes de confirmar de nuevo o desconéctate localmente. + Retira el acceso de este dispositivo y completa la limpieza local. Esto no elimina el perfil de Pubky. Editar Perfil Perfil Inténtelo de nuevo + Revisar Sugerencias Sugerencias para añadir Sí, desvincular diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5224935de6..7c24c2c224 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -441,10 +441,14 @@ Nota: Cualquier enlace que añada será públicamente visible. Añadir etiqueta Detalles + La limpieza del perfil está incompleta. Desconéctate localmente para retirar el acceso de este dispositivo sin volver a intentar la eliminación remota. Crear perfil + Es posible que algunos cambios se hayan completado. Revisa el estado actual antes de confirmar de nuevo o desconéctate localmente. + Retira el acceso de este dispositivo y completa la limpieza local. Esto no elimina el perfil de Pubky. Editar Perfil Perfil Inténtelo de nuevo + Revisar Sugerencias Sugerencias para añadir Sí, desvincular diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 06a89e56bd..be6028811c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -420,10 +420,14 @@ Autoriser Détails Autoriser + Le nettoyage du profil est incomplet. Déconnectez-vous localement pour retirer l’accès de cet appareil sans réessayer la suppression à distance. Créer un profil + Certaines modifications ont peut-être été effectuées. Vérifiez l’état actuel avant de confirmer à nouveau, ou déconnectez-vous localement. + Retirez l’accès de cet appareil et terminez le nettoyage local. Cela ne supprime pas le profil de Pubky. Modifier le profil Profil Réessayer + Vérifier Suggestions à ajouter Oui, dissocier Cela supprimera vos données Paykit et dissociera ce pubky de Bitkit. Il restera disponible dans Pubky Ring. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e0ebc9f571..2b0a71aa11 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -443,10 +443,14 @@ Nota: qualsiasi link aggiunto sarà visibile pubblicamente. Aggiungi Tag Dettagli + La pulizia del profilo è incompleta. Disconnettiti localmente per rimuovere l’accesso di questo dispositivo senza ritentare l’eliminazione remota. Crea Profilo + Alcune modifiche potrebbero essere state completate. Verifica lo stato attuale prima di confermare di nuovo, oppure disconnettiti localmente. + Rimuovi l’accesso di questo dispositivo e completa la pulizia locale. Il profilo non verrà eliminato da Pubky. Modifica Profilo Profilo Riprova + Verifica Consigli Suggerimenti Da Aggiungere Sì, Scollega diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index de56a9473b..6610fa0ede 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -441,10 +441,14 @@ Bijvoorbeeld \'Website\' Let op: Iedere link die u toevoegt is openbaar beschikbaar. Tag toevoegen + Het opschonen van het profiel is niet voltooid. Verbreek lokaal de verbinding om de toegang van dit apparaat te verwijderen zonder de verwijdering op afstand opnieuw te proberen. Profiel aanmaken + Sommige wijzigingen zijn mogelijk voltooid. Controleer de huidige status voordat je opnieuw bevestigt, of verbreek lokaal de verbinding. + Verwijder de toegang van dit apparaat en voltooi het lokaal opschonen. Dit verwijdert het profiel niet uit Pubky. Profiel Bewerken Profiel Probeer opnieuw + Controleren Suggesties Suggesties Om Toe Te Voegen Ja, ontkoppelen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 64f2590831..dd5a29a305 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -420,10 +420,14 @@ Autoryzuj Szczegóły Autoryzuj + Czyszczenie profilu nie zostało ukończone. Odłącz się lokalnie, aby usunąć dostęp tego urządzenia bez ponawiania zdalnego usuwania. Utwórz profil + Niektóre zmiany mogły zostać ukończone. Sprawdź bieżący stan przed ponownym potwierdzeniem lub odłącz się lokalnie. + Usuń dostęp tego urządzenia i dokończ lokalne czyszczenie. Nie spowoduje to usunięcia profilu z Pubky. Edytuj profil Profil Spróbuj ponownie + Sprawdź Sugestie Sugestie do dodania Tak, odłącz diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index af9986b902..88fcdc1761 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -448,10 +448,14 @@ Autorizar Detalhes Autorizar + A limpeza do perfil está incompleta. Desconecte-se localmente para remover o acesso deste dispositivo sem tentar novamente a exclusão remota. Criar Perfil + Algumas alterações podem ter sido concluídas. Revise o estado atual antes de confirmar novamente ou desconecte-se localmente. + Remova o acesso deste dispositivo e conclua a limpeza local. Isso não exclui o perfil do Pubky. Editar perfil Perfil Tente novamente + Revisar Sugestões Sugestões Para Adicionar Sim, Desvincular diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e49a4a0d16..06fd782814 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -398,9 +398,13 @@ Atualizar\n<accent>Bitkit</accent> Adicionar Tag Detalhes + A limpeza do perfil está incompleta. Desligue-se localmente para remover o acesso deste dispositivo sem voltar a tentar a eliminação remota. Criar Perfil + Algumas alterações poderão ter sido concluídas. Reveja o estado atual antes de confirmar novamente ou desligue-se localmente. + Remova o acesso deste dispositivo e conclua a limpeza local. Isto não elimina o perfil do Pubky. Perfil Tente novamente + Rever Sugestões Atualize a Bitkit para obter novos recursos e correções de bugs! Atualizar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7f841901c8..4b2a54d235 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -463,10 +463,14 @@ Подробности Ок Авторизовать + Очистка профиля не завершена. Отключитесь локально, чтобы убрать доступ этого устройства без повторной попытки удалённого удаления. Создать Профиль + Некоторые изменения могли быть выполнены. Проверьте текущее состояние перед повторным подтверждением или отключитесь локально. + Уберите доступ этого устройства и завершите локальную очистку. Профиль в Pubky удалён не будет. Редактировать Профиль Профиль Попробовать снова + Проверить Рекомендации Рекомендации для добавления Да, Отвязать diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab3c0af5e3..c841328888 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -689,6 +689,7 @@ Use an existing pubky from Pubky Ring. Loading your profile… Enter the\n<accent>freedom web</accent> + Profile cleanup is incomplete. Disconnect locally to remove this device’s access without retrying remote deletion. Failed to create profile Create Profile Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them. @@ -696,9 +697,11 @@ We couldn\'t delete your profile data. Retry, or disconnect this pubky from Bitkit. Unable to Delete Profile Delete Profile + Some changes may have completed. Review the current state before confirming again, or disconnect locally. Profile deleted Deriving your keys… Failed to disconnect profile + Remove this device’s access and finish local cleanup. This does not delete the profile from Pubky. BIO Short note. Tell a bit about yourself. DELETE @@ -725,6 +728,7 @@ Public Key Restore Profile Try Again + Review Your profile session has expired. Please reconnect to restore your profile. Disconnect This will disconnect your Pubky profile from Bitkit. You can reconnect at any time. diff --git a/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt b/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt new file mode 100644 index 0000000000..476cc1375d --- /dev/null +++ b/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt @@ -0,0 +1,203 @@ +package to.bitkit.data + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.IOException +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileRemovalStoreTest { + @get:Rule + val folder = TemporaryFolder() + + @Test + fun `observation fails closed with bounded retries and restarts after explicit durable completion`() = runTest { + val id = UUID.randomUUID().toString() + val state = MutableStateFlow(ProfileRemovalState(attemptId = id)) + var unavailable = true + var reads = 0 + val store = ProfileRemovalStore(object : DataStore { + override val data = flow { + reads++ + if (unavailable) throw IOException("Local storage unavailable") + emitAll(state) + } + override suspend fun updateData(transform: suspend (ProfileRemovalState) -> ProfileRemovalState) = + transform(state.value).also { state.value = it } + }) + val observed = mutableListOf() + backgroundScope.launch { store.pending.collect { observed += it } } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + assertEquals(4, reads) + assertEquals(true, observed.last()) + unavailable = false + store.withAccess(id) { store.complete(id) } + runCurrent() + assertEquals(false, observed.last()) + assertNull(store.pendingAttempt()) + } + + @Test + fun `restart retains pending without authorizing cleanup or another attempt`() = runTest { + val file = folder.newFile("removal").also { it.delete() } + val firstScope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) + val first = ProfileRemovalStore(DataStoreFactory.create(ProfileRemovalSerializer, scope = firstScope) { file }) + val attempt = UUID.randomUUID().toString() + try { + first.withAccess(attempt) { + first.begin(attempt) + first.requireAccess() + } + } finally { + requireNotNull(firstScope.coroutineContext[Job]).cancelAndJoin() + } + val second = + ProfileRemovalStore(DataStoreFactory.create(ProfileRemovalSerializer, scope = backgroundScope) { file }) + assertEquals(attempt, second.pendingAttempt()) + assertFailsWith { second.requireAccess() } + assertFailsWith { second.requireNoPending() } + assertFailsWith { second.complete(attempt) } + assertFailsWith { second.withAccess(UUID.randomUUID().toString()) {} } + second.withAccess(attempt) { + second.requireAccess() + second.complete(attempt) + } + assertNull(second.pendingAttempt()) + } + + @Test + fun `an escaped access context cannot authorize work after its scope finishes`() = runTest { + val store = ProfileRemovalStore.inMemoryForTesting() + val id = UUID.randomUUID().toString() + val stale = store.withAccess(id) { + store.begin(id) + currentCoroutineContext().minusKey(Job) + } + assertFailsWith { withContext(stale) { store.requireAccess() } } + assertEquals(id, store.pendingAttempt()) + } + + @Test + fun `a foreign coroutine cannot use a pending owner's access`() = runTest { + val store = ProfileRemovalStore.inMemoryForTesting() + val id = UUID.randomUUID().toString() + store.withAccess(id) { + store.begin(id) + val foreign = backgroundScope.async { assertFailsWith { store.requireAccess() } } + foreign.await() + store.requireAccess() + assertFailsWith { store.complete(UUID.randomUUID().toString()) } + assertEquals(id, store.pendingAttempt()) + } + } + + @Test + fun `malformed persisted marker becomes stable pending recovery across restart`() = runTest { + val file = folder.newFile("malformed").also { it.writeText("{broken") } + val firstScope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) + val first = ProfileRemovalStore( + DataStoreFactory.create( + ProfileRemovalSerializer, + corruptionHandler = profileRemovalCorruptionHandler(), + scope = firstScope, + ) { file } + ) + val recovered = try { + assertNotNull(first.pendingAttempt()).also { + assertFailsWith { first.requireNoPending() } + assertFailsWith { first.complete(it) } + } + } finally { + requireNotNull(firstScope.coroutineContext[Job]).cancelAndJoin() + } + val second = ProfileRemovalStore( + DataStoreFactory.create( + ProfileRemovalSerializer, + corruptionHandler = profileRemovalCorruptionHandler(), + scope = backgroundScope, + ) { file } + ) + assertEquals(recovered, second.pendingAttempt()) + second.withAccess(recovered) { second.complete(recovered) } + assertNull(second.pendingAttempt()) + } + + @Test + fun `future version with incompatible fields stays intact and cannot be mistaken for corrupt v1`() = runTest { + for (version in listOf("2", "\"1\"", "null", "1.5")) { + val bytes = "{\"version\":$version,\"attemptId\":{\"future\":true}}".toByteArray() + val file = folder.newFile().also { it.writeBytes(bytes) } + val scope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) + val store = ProfileRemovalStore( + DataStoreFactory.create( + ProfileRemovalSerializer, + corruptionHandler = profileRemovalCorruptionHandler(), + scope = scope, + ) { file } + ) + try { + val failure = assertFailsWith { store.pendingAttempt() } + assertTrue( + generateSequence(failure) { it.cause } + .any { it is UnsupportedProfileRemovalFormat } + ) + assertTrue(bytes.contentEquals(file.readBytes())) + } finally { + requireNotNull(scope.coroutineContext[Job]).cancelAndJoin() + } + } + } + + @Test + fun `input read failure is not classified as replaceable corruption`() = runTest { + val failure = IOException("storage unavailable") + val stream = object : java.io.InputStream() { + override fun read(): Int = throw failure + } + assertEquals(failure, assertFailsWith { ProfileRemovalSerializer.readFrom(stream) }) + } + + @Test + fun `corrupt recovery state is never interpreted as no pending work`() = runTest { + for (bytes in listOf( + "not json".toByteArray(), + "{\"attemptId\":\"bad\"}".toByteArray() + )) { + assertFailsWith { ProfileRemovalSerializer.readFrom(ByteArrayInputStream(bytes)) } + } + assertFailsWith { + ProfileRemovalSerializer.readFrom( + ByteArrayInputStream(byteArrayOf(-1)) + ) + } + assertEquals(ProfileRemovalState(), ProfileRemovalSerializer.readFrom(ByteArrayInputStream("{}".toByteArray()))) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 1eee30e7dd..188ae1f8c2 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -193,6 +193,13 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `profile backup retry admission does not retry unrelated storage failures`() { + assertFalse(PaykitException.Storage("wallet_wipe_in_progress", "wipe").isTemporaryBackupFailure()) + assertFalse(AppError(PaykitException.Storage("wallet_wipe_in_progress", "wipe")).isTemporaryBackupFailure()) + assertFalse(PaykitException.Storage("unknown", "profile_removal_in_progress").isTemporaryBackupFailure()) + } + @Test fun `metadata backup preserves complete data when identity records cannot be read`() = test { stubMetadataBackupReads() @@ -1228,6 +1235,45 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `wallet backup retains pending work while profile removal owns the SDK`() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) + whenever { privatePaykitRepo.backupSnapshot() } + .thenReturn( + Result.failure(AppError(PaykitException.Storage("profile_removal_in_progress", "removal owns SDK"))) + ) + .thenReturn(Result.success(PrivatePaykitBackupSnapshot(null, 0))) + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.WALLET to BackupItemStatus( + synced = 1_000, + required = 2_000, + ), + ) + ) + val allowWalletClear = CompletableDeferred().apply { complete(Unit) } + stubBackupStatuses(backupStatuses, allowWalletClear) {} + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + verify(privatePaykitRepo, times(1)).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + advanceTimeBy(20_000) + runCurrent() + + verify(privatePaykitRepo, times(2)).backupSnapshot() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + assertFalse(backupStatuses.value.getValue(BackupCategory.WALLET).isRequired) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `wallet backup retries after a busy Paykit shared state`() = test { whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 2adacf44ff..41a7055272 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin @@ -10,6 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -70,6 +73,92 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) } + @Test + fun `wipe revokes repository removal before draining the sharing mutex`() = test { + val sut = createSut() + val repository = PubkyIdentityOperations() + val entered = CompletableDeferred() + val cleanup = CompletableDeferred() + val finishCleanup = CompletableDeferred() + val removing = launch { + sut.withProfileRemoval { + repository.remove { + entered.complete(Unit) + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + cleanup.complete(Unit) + finishCleanup.await() + } + } + } + } + } + entered.await() + var reachedSdk = false + val wiping = async { + sut.withWalletWipe(repository::wipe) { reachedSdk = true } + } + cleanup.await() + assertFalse(reachedSdk) + assertTrue(sut.setEnabled(true).isFailure) + finishCleanup.complete(Unit) + withTimeout(1_000) { + wiping.await() + removing.join() + } + assertTrue(reachedSdk) + assertTrue(sut.setEnabled(false).isSuccess) + } + + @Test + fun `removal drains sharing rollback and rejects queued changes`() = test { + val publication = CompletableDeferred() + val enteredRemoval = CompletableDeferred() + val finishRemoval = CompletableDeferred() + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = true)).doSuspendableAnswer { + publication.await() + Result.failure(IllegalStateException("Uncertain publication")) + } + val sut = createSut() + val enabling = async { sut.setEnabled(true) } + runCurrent() + val queued = async { sut.setEnabled(false) } + runCurrent() + val removing = async { + sut.withProfileRemoval { + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + enteredRemoval.complete(Unit) + finishRemoval.await() + } + } + runCurrent() + assertFalse(enteredRemoval.isCompleted) + assertTrue(sut.disablePaykit().isFailure) + publication.complete(Unit) + runCurrent() + assertTrue(enabling.await().isFailure) + assertTrue(queued.await().isFailure) + assertTrue(enteredRemoval.isCompleted) + var reconciled = false + sut.reconcilePendingEndpoints { reconciled = true } + assertFalse(reconciled) + finishRemoval.complete(Unit) + removing.await() + assertTrue(sut.setEnabled(false).isSuccess) + } + + @Test + fun `cancelled removal reservation releases sharing admission`() = test { + val sut = createSut() + val removal = launch { sut.withProfileRemoval { awaitCancellation() } } + runCurrent() + assertTrue(sut.setEnabled(true).isFailure) + removal.cancelAndJoin() + assertTrue(sut.setEnabled(true).isSuccess) + } + @Test fun `enabling publishes public and private contact payments`() = test { settingsFlow.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index 076c74f7c4..0381dbf584 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -20,6 +20,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.ext.toHex @@ -50,6 +51,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) } private val paykitSdkService = mock() + private val profileRemovalStore = ProfileRemovalStore.inMemoryForTesting() private val cacheStore = mock() private val settingsStore = mock() private val lightningRepo = mock() @@ -84,6 +86,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) usdtRepo = mock(), coreService = coreService, clock = clock, + profileRemovalStore = profileRemovalStore, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index 500c8252a3..6054e08c34 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -1,19 +1,26 @@ package to.bitkit.repositories +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import org.junit.Before import org.junit.Test +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.PrivatePaykitStoredInvoiceData +import to.bitkit.data.ProfileRemovalStore import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.test.BaseUnitTest +import java.util.UUID import javax.inject.Provider import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class PrivatePaykitContactResolverTest : BaseUnitTest() { companion object { @@ -22,6 +29,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { private const val PRIVATE_ADDRESS = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" } + private val profileRemovalStore = ProfileRemovalStore.inMemoryForTesting() private val cacheStore = mock() private val addressReservationRepo = mock() private val paymentRequestRepo = mock() @@ -38,9 +46,73 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { cacheStore = cacheStore, addressReservationRepo = Provider { addressReservationRepo }, paymentRequestRepo = Provider { paymentRequestRepo }, + profileRemovalStore = profileRemovalStore, ) } + @Test + fun `recovery generation discards a cold invoice lookup across completed local cleanup`() = test { + val old = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData(receivedInvoicePaymentHashes = listOf(PAYMENT_HASH)) + ) + ) + val ready = CompletableDeferred() + val release = CompletableDeferred() + whenever(cacheStore.data).thenReturn( + flow { + ready.complete(Unit) + release.await() + emit(old) + } + ) + val lookup = async { sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) } + ready.await() + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { + profileRemovalStore.begin(id) + cacheData.value = PrivatePaykitCacheData() + profileRemovalStore.complete(id) + } + release.complete(Unit) + assertNull(lookup.await()) + } + + @Test + fun `recovery generation discards a reserved contact lookup across completed local cleanup`() = test { + val ready = CompletableDeferred() + val release = CompletableDeferred() + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).doSuspendableAnswer { + ready.complete(Unit) + release.await() + CONTACT_KEY + } + val lookup = async { sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS) } + ready.await() + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { + profileRemovalStore.begin(id) + profileRemovalStore.complete(id) + } + release.complete(Unit) + assertNull(lookup.await()) + } + + @Test + fun `pending removal hides invoice and address contacts without erasing reservation state`() = test { + val shared = mock() + whenever(shared.contactsForPaymentHash(PAYMENT_HASH)).thenReturn(setOf(CONTACT_KEY)) + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + assertNull(sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH)) + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) + assertTrue(sut.isReservedReceiveAddress(PRIVATE_ADDRESS)) + } + @Test fun `shared request invoice hash resolves without local invoice reservations`() = test { val shared = mock() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5e0aa48fea..2f3bbd7bbb 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -60,6 +61,7 @@ import to.bitkit.CurrentActivity import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PrivatePaykitContactCacheData +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.UsdtPreference @@ -77,6 +79,7 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -104,6 +107,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val pubkyService = mock() + private val profileRemovalStore = ProfileRemovalStore.inMemoryForTesting() private val cacheStore = mock() private val settingsStore = mock() private val addressReservationRepo = mock() @@ -201,6 +205,170 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { App.currentActivity = null } + private var deletionPause: PrivatePaykitRepo.ProfileDeletionPause? = null + + private suspend fun beginProfileDeletion() { + deletionPause = sut.beginProfileDeletion() + } + + private suspend fun endProfileDeletion() { + deletionPause?.let { sut.endProfileDeletion(it) } + deletionPause = null + } + + @Test + fun `recovery admission rejects a cold attribution result after pending begins`() = test { + val old = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData(receivedInvoicePaymentHashes = listOf("old-hash")) + ) + ) + val ready = CompletableDeferred() + val release = CompletableDeferred() + whenever(cacheStore.data).thenReturn( + flow { + ready.complete(Unit) + release.await() + emit(old) + } + ) + val reading = async { sut.contactPublicKeyForPrivateInvoicePaymentHash("old-hash") } + ready.await() + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + release.complete(Unit) + assertNull(reading.await()) + assertEquals(id, profileRemovalStore.pendingAttempt()) + } + + @Test + fun `recovery admission prevents an old cold cache read overwriting completed local teardown`() = test { + val old = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData(receivedInvoicePaymentHashes = listOf("old-hash")) + ) + ) + val ready = CompletableDeferred() + val release = CompletableDeferred() + whenever(cacheStore.data).thenReturn( + flow { + ready.complete(Unit) + release.await() + emit(old) + } + ) + val reading = async { sut.contactPublicKeyForPrivateInvoicePaymentHash("old-hash") } + ready.await() + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { + profileRemovalStore.begin(id) + sut.withProfileDeletionPaused { pause -> sut.finishProfileRemoval(pause).getOrThrow() } + profileRemovalStore.complete(id) + } + release.complete(Unit) + assertNull(reading.await()) + assertNull(sut.contactPublicKeyForPrivateInvoicePaymentHash("old-hash")) + assertTrue(cacheData.value.contacts.isEmpty()) + } + + @Test + fun `profile removal recovery leaves ordinary incoming payment attribution optional`() = test { + assertNull(sut.contactPublicKeyForPrivateInvoicePaymentHash("ordinary-payment")) + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + assertNull(sut.contactPublicKeyForPrivateInvoicePaymentHash("ordinary-payment")) + } + + @Test + fun `pending removal prevents a complete backup before touching authenticated identity`() = test { + whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(pubkyService.currentPublicKey()).thenReturn(null) + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + clearInvocations(pubkyService, paykitSdkService) + assertTrue(sut.backupSnapshot().isFailure) + verify(pubkyService, never()).currentPublicKey() + verify(paykitSdkService, never()).exportBackupState(any()) + } + + @Test + fun `pending removal prevents destructive cache clearing while credentials may survive`() = test { + val id = UUID.randomUUID().toString() + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData(consumedPrivatePaymentListVersion = 7uL) + ) + ) + val original = cacheData.value + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + assertTrue(sut.closeAndClear().isFailure) + assertTrue(sut.restoreBackup(null).isFailure) + assertEquals(original, cacheData.value) + verify(paykitSdkService, never()).clearState() + verify(addressReservationRepo, never()).clearContactAssignments(any(), anyOrNull()) + } + + @Test + fun `pending removal survives process recreation and ending a temporary pause`() = test { + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + val restarted = createSut() + runCurrent() + assertTrue(restarted.isProfileDeletionInProgress.value) + val pause = restarted.beginProfileDeletion() + restarted.endProfileDeletion(pause) + assertTrue(restarted.isProfileDeletionInProgress.value) + profileRemovalStore.withAccess(id) { profileRemovalStore.complete(id) } + runCurrent() + assertFalse(restarted.isProfileDeletionInProgress.value) + } + + @Test + fun `cancellation while returning a deletion pause cannot retain it`() = test { + val returns = ArrayDeque() + val caller = object : kotlinx.coroutines.CoroutineDispatcher() { + override fun dispatch(context: kotlin.coroutines.CoroutineContext, block: Runnable) { + returns.addLast(block) + } + } + var entered = false + val removal = launch(caller) { sut.withProfileDeletionPaused { entered = true } } + returns.removeFirst().run() + runCurrent() + assertTrue(sut.isProfileDeletionInProgress.value) + removal.cancel() + while (returns.isNotEmpty()) { + returns.removeFirst().run() + runCurrent() + } + assertTrue(removal.isCompleted) + assertFalse(entered) + assertFalse(sut.isProfileDeletionInProgress.value) + } + + @Test + fun `ending one removal pause cannot unpause another owner`() = test { + val first = sut.beginProfileDeletion() + val second = sut.beginProfileDeletion() + sut.endProfileDeletion(first) + assertTrue(sut.isProfileDeletionInProgress.value) + sut.endProfileDeletion(first) + assertTrue(sut.isProfileDeletionInProgress.value) + sut.endProfileDeletion(second) + assertFalse(sut.isProfileDeletionInProgress.value) + } + + @Test + fun `profile removal finalization is local and rejects an ended pause`() = test { + val pause = sut.beginProfileDeletion() + assertTrue(sut.finishProfileRemoval(pause).isSuccess) + verify(paykitSdkService, never()).clearState() + sut.endProfileDeletion(pause) + clearInvocations(cacheStore) + assertTrue(sut.finishProfileRemoval(pause).isFailure) + verify(cacheStore, never()).reset() + } + @Test fun `obsolete contact sync preserves the admitted retry and its original priority deadline`() = test { whenever(clock.now()).thenAnswer { @@ -1162,7 +1330,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { "identity" -> whenever(paykitSdkService.identityStatus()).thenReturn( IdentityStatus(OTHER_CONTACT_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), ) - "deletion" -> sut.beginProfileDeletion() + "deletion" -> beginProfileDeletion() } completion.complete(Unit) runCurrent() @@ -1179,7 +1347,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } finally { completion.complete(Unit) sut.closeAndClear() - sut.endProfileDeletion() + endProfileDeletion() whenever(paykitSdkService.identityStatus()).thenReturn( IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), ) @@ -1558,7 +1726,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() runCurrent() - sut.beginProfileDeletion() + beginProfileDeletion() clearInvocations(paykitSdkService) completion.complete(Unit) advanceTimeBy(120_000) @@ -1660,7 +1828,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.setContactPreparationActive(false) completion.complete(Unit) runCurrent() - sut.beginProfileDeletion() + beginProfileDeletion() clearInvocations(paykitSdkService) sut.setContactPreparationActive(true) @@ -1697,7 +1865,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.scheduleSavedContactPreparation(listOf(OTHER_CONTACT_KEY)).getOrThrow() advanceTimeBy(2_000) runCurrent() - sut.beginProfileDeletion() + beginProfileDeletion() clearInvocations(paykitSdkService) sut.setContactPreparationActive(true) @@ -1893,8 +2061,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val old = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } runCurrent() assertEquals(1, advances) - sut.beginProfileDeletion() - sut.endProfileDeletion() + beginProfileDeletion() + endProfileDeletion() val current = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } try { runCurrent() @@ -2851,7 +3019,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitUsdtEnabled = false, ) - sut.beginProfileDeletion() + beginProfileDeletion() sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() runCurrent() verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) @@ -2860,7 +3028,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).clearPrivatePaymentLists(any(), anyOrNull()) verify(addressReservationRepo).removeContactAssignments(setOf(CONTACT_KEY, OTHER_CONTACT_KEY)) - sut.endProfileDeletion() + endProfileDeletion() sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() runCurrent() verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) @@ -2870,7 +3038,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `deferred private Lightning refresh survives cleanup and failed publication`() = test { preparePrivateLightningContact() - sut.beginProfileDeletion() + beginProfileDeletion() clearInvocations(paykitSdkService, pubkyService) sut.refreshKnownSavedContactEndpoints("channel ready", forceRefreshLightning = true).getOrThrow() verifyNoInteractions(paykitSdkService, pubkyService) @@ -2878,7 +3046,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.clearPrivatePaymentLists(any(), anyOrNull())) .thenAnswer { throw AppError("cleanup unavailable") } assertTrue(sut.removePublishedEndpointsForCleanup("profile deletion").isFailure) - sut.endProfileDeletion() + endProfileDeletion() settingsData.update { it.copy(sharesPrivatePaykitEndpoints = false) } sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() runCurrent() @@ -3000,14 +3168,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.scheduleSavedContactPreparation(emptyList()).getOrThrow() runCurrent() var isCurrent = true - sut.beginProfileDeletion() + beginProfileDeletion() sut.refreshKnownSavedContactEndpoints( "channel ready", forceRefreshLightning = true, isStillCurrent = { isCurrent }, ).getOrThrow() isCurrent = false - sut.endProfileDeletion() + endProfileDeletion() sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() runCurrent() @@ -3068,7 +3236,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - sut.beginProfileDeletion() + beginProfileDeletion() clearInvocations(paykitSdkService) sut.handleReceivedPayment("090909").getOrThrow() @@ -3076,7 +3244,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(CONTACT_KEY, sut.contactPublicKeyForPrivateInvoicePaymentHash("090909")) assertEquals(listOf("090909"), cacheData.value.contacts.getValue(CONTACT_KEY).receivedInvoicePaymentHashes) verifyNoInteractions(paykitSdkService) - sut.endProfileDeletion() + endProfileDeletion() sut.refreshKnownSavedContactEndpoints("deletion ended").getOrThrow() runCurrent() verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) @@ -3094,14 +3262,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(addressReservationRepo.contactsWithUsedReservedAddresses()).thenReturn(listOf(CONTACT_KEY)) whenever(addressReservationRepo.currentContactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) .thenReturn(CONTACT_KEY) - sut.beginProfileDeletion() + beginProfileDeletion() clearInvocations(paykitSdkService) sut.handleOnchainActivity().getOrThrow() sut.handleOnchainActivity(listOf(PRIVATE_ADDRESS)).getOrThrow() verifyNoInteractions(paykitSdkService) - sut.endProfileDeletion() + endProfileDeletion() sut.refreshKnownSavedContactEndpoints("deletion ended").getOrThrow() runCurrent() verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) @@ -3135,8 +3303,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val rotation = async { sut.handleOnchainActivity() } runCurrent() assertTrue(identityStarted.isCompleted) - sut.beginProfileDeletion() - sut.endProfileDeletion() + beginProfileDeletion() + endProfileDeletion() resumeIdentity.complete(Unit) rotation.await().getOrThrow() verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) @@ -3164,7 +3332,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() runCurrent() assertTrue(settingsStarted.isCompleted) - sut.beginProfileDeletion() + beginProfileDeletion() sut.removePublishedEndpointsForCleanup("profile deletion").getOrThrow() assertFalse(cacheData.value.cleanupPending) clearInvocations(paykitSdkService) @@ -3173,7 +3341,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.awaitContactPreparation() verifyNoInteractions(paykitSdkService) - sut.endProfileDeletion() + endProfileDeletion() sut.closeAndClear() } @@ -4105,6 +4273,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { usdtRepo = usdtRepo, coreService = coreService, clock = clock, + profileRemovalStore = profileRemovalStore, ).also { it.setContactPreparationActive(true) } private fun resolution( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyIdentityOperationsTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyIdentityOperationsTest.kt new file mode 100644 index 0000000000..8fd7ee3c23 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PubkyIdentityOperationsTest.kt @@ -0,0 +1,142 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Test +import kotlin.coroutines.CoroutineContext +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PubkyIdentityOperationsTest { + @Test + fun `removal excludes identity replacement through local completion`() = runTest { + val operations = PubkyIdentityOperations() + val local = CompletableDeferred() + var writes = 0 + val removal = launch { + operations.remove { + operations.transition { writes++ } + local.await() + } + } + runCurrent() + assertFailsWith { operations.transition { writes++ } } + assertFailsWith { operations.remove { writes++ } } + assertEquals(1, writes) + local.complete(Unit) + removal.join() + operations.transition { writes++ } + assertEquals(2, writes) + } + + @Test + fun `concurrent transition tails finish before removal can start`() = runTest { + val operations = PubkyIdentityOperations() + val first = CompletableDeferred() + val second = CompletableDeferred() + val a = launch { operations.transition { first.await() } } + val b = launch { operations.transition { second.await() } } + runCurrent() + first.complete(Unit) + a.join() + assertFailsWith { operations.remove {} } + second.complete(Unit) + b.join() + operations.remove {} + } + + @Test + fun `wipe revokes removal and drains noncancellable local cleanup before reset`() = runTest { + val operations = PubkyIdentityOperations() + val local = CompletableDeferred() + val events = mutableListOf() + val removal = launch { + operations.remove { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + local.await() + operations.transition { events += "local" } + } + } + } + } + runCurrent() + val wipe = launch { operations.wipe { operations.transition { events += "wipe" } } } + try { + runCurrent() + assertFalse(wipe.isCompleted) + assertFailsWith { operations.transition {} } + assertFailsWith { operations.remove {} } + local.complete(Unit) + removal.join() + wipe.join() + assertEquals(listOf("local", "wipe"), events) + operations.transition {} + } finally { + local.complete(Unit) + removal.cancel() + wipe.cancel() + } + } + + @Test + fun `wipe drains an admitted transition rollback and rejects new identity work`() = runTest { + val operations = PubkyIdentityOperations() + val rollback = CompletableDeferred() + var restored = false + val transition = launch { + operations.transition { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + rollback.await() + operations.transition { restored = true } + } + } + } + } + runCurrent() + transition.cancel() + val wipe = launch { operations.wipe { assertTrue(restored) } } + try { + runCurrent() + assertFalse(wipe.isCompleted) + assertFailsWith { operations.remove {} } + rollback.complete(Unit) + transition.join() + wipe.join() + } finally { + rollback.complete(Unit) + transition.cancel() + wipe.cancel() + } + } + + @Test + fun `ended context cannot authorize later work and nested wipe cannot self-deadlock`() = runTest { + val operations = PubkyIdentityOperations() + lateinit var ended: CoroutineContext + operations.remove { + ended = currentCoroutineContext() + assertFailsWith { operations.wipe {} } + } + // Preserve the context element but use the current active Job for this deliberately stale continuation. + withContext(ended.minusKey(kotlinx.coroutines.Job)) { + assertFailsWith { operations.transition {} } + } + operations.remove {} + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index a4d18e2836..4f753fd1eb 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -66,6 +66,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import org.mockito.verification.VerificationMode +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyCachedProfile import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyStore @@ -82,14 +83,19 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.services.PaykitProfileDeletionSnapshot import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyRingAuthTimeoutError import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.test.forEachCase +import to.bitkit.usecases.ProfileRemovalCoordinator +import to.bitkit.usecases.ProfileRemovalFailure import to.bitkit.utils.AppError +import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -129,11 +135,172 @@ class PubkyRepoTest : BaseUnitTest() { private lateinit var sut: PubkyRepo + @Test + fun `removal rejects every competing identity entry before it changes credentials or settings`() = test { + advanceUntilIdle() + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + it.getArgument Unit>(1).invoke() + } + val release = CompletableDeferred() + val removal = async { sut.performProfileRemoval(sut.profileOwner()) { release.await() } } + runCurrent() + clearInvocations(pubkyService, keychain, settingsStore) + val entries = listOf Result<*>>( + { sut.initialize() }, + { sut.checkAdoptedSource() }, + { sut.adoptRingIdentity(VALID_SELF_KEY) }, + { sut.createIdentity("Name", "", emptyList(), emptyList(), null) }, + { sut.saveProfile("Name", "", emptyList(), emptyList(), null) }, + { sut.approveSignupAuth(directSignupRequest()) }, + { sut.approveAuth("auth", "capabilities", "client") }, + { sut.approveAuthWithCompanionClaim("auth", "client", byteArrayOf()) }, + { sut.restoreSessionBackupState(null) }, + { sut.signOut() }, + { sut.forgetUnrestoredIdentity() }, + ) + try { + entries.forEach { assertSame(PubkyIdentityBusy, it().exceptionOrNull()) } + assertFalse(sut.hasSecretKey()) + assertFalse(sut.restoreSessionIfNeeded()) + assertFailsWith { sut.wipeLocalState() } + verify(keychain, never()).delete(any()) + verify(keychain, never()).upsertString(any(), any()) + verify(settingsStore, never()).update(any()) + verify(pubkyService, never()).signOut() + verify(pubkyService, never()).forgetSessionAccess() + } finally { + release.complete(Unit) + } + assertTrue(removal.await().isSuccess) + } + + @Test + fun `removal cannot start during the local tail of Ring adoption`() = test { + advanceUntilIdle() + val ringPubky = stubRingCredential() + val releaseContacts = CompletableDeferred() + whenever(pubkyService.contactRecords()).doSuspendableAnswer { + releaseContacts.await() + emptyList() + } + val adoption = async { sut.adoptRingIdentity(ringPubky) } + try { + runCurrent() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertFalse(adoption.isCompleted) + val removal = sut.performProfileRemoval(sut.profileOwner()) { error("removal admitted during adoption") } + assertSame(PubkyIdentityBusy, removal.exceptionOrNull()) + } finally { + releaseContacts.complete(Unit) + } + assertTrue(adoption.await().isSuccess) + } + + @Test + fun `removal preserves its original owner and returns the ended generation after signout`() = test { + authenticateForTesting() + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + assertTrue(it.getArgument<() -> Boolean>(0).invoke()) + it.getArgument Unit>(1).invoke() + } + val original = sut.profileOwner() + val completion = sut.performProfileRemoval(original) { sut.finishProfileRemovalLocally() }.getOrThrow() + assertFalse(sut.isProfileOwnerCurrent(original)) + assertTrue(sut.isProfileOwnerCurrent(completion)) + assertNull(sut.publicKey.value) + assertTrue(sut.performProfileRemoval(original) { error("obsolete owner admitted") }.isFailure) + } + + @Test + fun `pending removal restart refuses activation and settles restoration without clearing credentials`() = test { + advanceUntilIdle() + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") + clearInvocations(pubkyService, keychain) + val restarted = createSut() + advanceUntilIdle() + assertFalse(restarted.isRestoringSession.value) + assertTrue(restarted.sessionRestorationFailed.value) + assertFalse(restarted.restoreSessionIfNeeded()) + assertEquals(PubkyIdentityReadiness.RecoveryRequired, restarted.awaitIdentityReady()) + assertTrue(restarted.initialize().isFailure) + verify(pubkyService, never()).initialize() + verify(pubkyService, never()).initializeAndImportSession(any()) + verify(keychain, never()).delete(any()) + assertEquals(id, profileRemovalStore.pendingAttempt()) + } + + @Test + fun `pending removal rejects new identity and reports unavailable even with an old active profile`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + clearInvocations(pubkyService, keychain) + assertEquals(PubkyIdentityReadiness.RecoveryRequired, sut.awaitIdentityReady()) + assertTrue(sut.adoptRingIdentity(VALID_CONTACT_KEY_A).isFailure) + assertTrue(sut.restoreSessionBackupState(null).isFailure) + assertTrue(sut.republishIdentityIfNeeded().isFailure) + verify(keychain, never()).delete(any()) + assertEquals(id, profileRemovalStore.pendingAttempt()) + } + + @Test + fun `pending removal grants exact marker scope before SDK admission`() = test { + advanceUntilIdle() + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + profileRemovalStore.requireAccess() + it.getArgument Unit>(1).invoke() + } + var reached = false + assertTrue(sut.performProfileRemoval(sut.profileOwner(), id) { reached = true }.isSuccess) + assertTrue(reached) + assertTrue(sut.performProfileRemoval(sut.profileOwner(), UUID.randomUUID().toString()) {}.isFailure) + assertEquals(id, profileRemovalStore.pendingAttempt()) + } + + @Test + fun `strict local completion does not clear metadata when credentials cannot be forgotten`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val owner = sut.profileOwner() + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + it.getArgument Unit>(1).invoke() + } + whenever { pubkyService.forgetLocalProfileAccess() }.thenAnswer { error("keychain unavailable") } + clearInvocations(pubkyStore, settingsStore) + assertTrue(sut.performProfileRemoval(owner) { sut.finishProfileRemovalLocally() }.isFailure) + assertTrue(sut.isProfileOwnerCurrent(owner)) + verify(pubkyStore, never()).reset() + verify(settingsStore, never()).update(any()) + verify(pubkyService, never()).signOut() + } + + @Test + fun `strict local completion ends old writers before reset and propagates cache failure`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val owner = sut.profileOwner() + val version = sut.backupStateVersion.value + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + it.getArgument Unit>(1).invoke() + } + whenever { pubkyStore.reset() }.thenAnswer { + assertFalse(sut.isProfileOwnerCurrent(owner)) + error("cache unavailable") + } + assertTrue(sut.performProfileRemoval(owner) { sut.finishProfileRemovalLocally() }.isFailure) + assertNull(sut.publicKey.value) + assertTrue(sut.backupStateVersion.value > version) + verify(pubkyService).forgetLocalProfileAccess() + } + private val pubkyService = mock() private val keychain = mock() private val sharedPubkyClient = mock() private val imageLoader = mock() private val imageCacheEpoch = PubkyImageCacheEpoch() + private val profileRemovalStore = ProfileRemovalStore.inMemoryForTesting() private val pubkyStore = mock() private val settingsStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) @@ -173,6 +340,18 @@ class PubkyRepoTest : BaseUnitTest() { adoptedSource = null Unit } + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + check(it.getArgument<() -> Boolean>(0).invoke()) + it.getArgument Unit>(1).invoke() + } + whenever { pubkyService.captureProfileDeletion(any(), any()) }.doSuspendableAnswer { + PaykitProfileDeletionSnapshot(it.getArgument(0), "revision", pubkyService.contactRecords()) + } + whenever { pubkyService.forgetLocalProfileAccess() }.doSuspendableAnswer { + keychain.delete(Keychain.Key.PAYKIT_SESSION.name) + keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) + } sut = createSut() } @@ -187,6 +366,44 @@ class PubkyRepoTest : BaseUnitTest() { settingsStore = settingsStore, httpClient = httpClient, clock = clock, + profileRemovalStore = profileRemovalStore, + ) + + private fun PubkyRepo.removalCoordinatorForTest(): ProfileRemovalCoordinator { + val privateRepo = mock() + val sharing = mock() + whenever { sharing.withProfileRemoval(any()) }.doSuspendableAnswer { + it.getArgument PubkyProfileOwner>(0).invoke() + } + whenever { privateRepo.withProfileDeletionPaused(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0) + .invoke(PrivatePaykitRepo.ProfileDeletionPause()) + } + whenever { privateRepo.removePublishedEndpointsForCleanup(any(), eq(true)) }.thenReturn(Result.success(Unit)) + whenever { privateRepo.finishProfileRemoval(any()) }.thenReturn(Result.success(Unit)) + return ProfileRemovalCoordinator(this, privateRepo, sharing, profileRemovalStore) + } + + private suspend fun PubkyRepo.signOut(): Result = runSuspendCatching { + val coordinator = removalCoordinatorForTest() + coordinator.remove(coordinator.prepareSignOut(profileOwner()).getOrThrow()).repositoryResult().getOrThrow() + } + + private suspend fun PubkyRepo.deleteProfile(): Result = runSuspendCatching { + val coordinator = removalCoordinatorForTest() + coordinator.remove(coordinator.prepareDeletion(profileOwner()).getOrThrow()).repositoryResult().getOrThrow() + } + + private suspend fun PubkyRepo.forgetUnrestoredIdentity(): Result = runSuspendCatching { + check(publicKey.value == null) + signOut().getOrThrow() + true + } + + /** These cases assert the repository failure; coordinator failure metadata has its own integration coverage. */ + private fun Result.repositoryResult(): Result = fold( + onSuccess = { Result.success(Unit) }, + onFailure = { Result.failure((it as? ProfileRemovalFailure)?.cause ?: it) }, ) private fun stubAdoptedRingSource() { @@ -1139,18 +1356,148 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `hasSecretKey should mark backup state changed when stale local secret is removed`() = test { + fun `credential availability remains readable while failed removal retains its identity`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("current_secret") + whenever(pubkyService.publicKeyFromSecret("current_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + it.getArgument Unit>(1).invoke() + } + val release = CompletableDeferred() + val removal = async { + sut.performProfileRemoval(sut.profileOwner()) { + release.await() + throw TestAppError("unknown deletion outcome") + } + } + try { + runCurrent() + assertTrue(sut.hasSecretKey(), "temporary removal admission is not absence of the credential") + } finally { + release.complete(Unit) + } + assertTrue(removal.await().isFailure) + assertTrue(sut.hasSecretKey()) + } + + @Test + fun `cancelled credential derivation preserves the managed secret`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("current_secret") + val entered = CompletableDeferred() + whenever(pubkyService.publicKeyFromSecret("current_secret")).doSuspendableAnswer { + entered.complete(Unit) + awaitCancellation() + } + clearInvocations(keychain) + val read = launch { sut.hasSecretKey() } + entered.await() + read.cancelAndJoin() + verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + + @Test + fun `credential validation drains before identity replacement can install its secret`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val ringPubky = stubRingCredential(VALID_CONTACT_KEY_A) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("current_secret") + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(pubkyService.publicKeyFromSecret("current_secret")).doSuspendableAnswer { + entered.complete(Unit) + release.await() + VALID_SELF_KEY.removePrefix("pubky") + } + val read = async { sut.hasSecretKey() } + entered.await() + val adoption = async { sut.adoptRingIdentity(ringPubky) } + try { + runCurrent() + assertFalse(adoption.isCompleted, "replacement must not overtake managed credential validation") + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } finally { + release.complete(Unit) + } + assertTrue(read.await()) + assertTrue(adoption.await().isSuccess) + assertEquals(VALID_CONTACT_KEY_A, sut.publicKey.value) + } + + @Test + fun `credential availability never deletes a stale local secret`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val staleSecret = "stale_secret" whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(staleSecret) whenever(pubkyService.publicKeyFromSecret(staleSecret)).thenReturn(VALID_CONTACT_KEY_A.removePrefix("pubky")) val backupVersion = sut.backupStateVersion.value + clearInvocations(keychain) + + assertFalse(sut.hasSecretKey()) - val result = sut.hasSecretKey() + assertEquals(backupVersion, sut.backupStateVersion.value) + verify(keychain, never()).delete(any()) + } - assertFalse(result) + @Test + fun `credential availability can read the matching Ring key without removing a stale local key`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + stubAdoptedRingSource() + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("stale_secret") + whenever(pubkyService.publicKeyFromSecret("stale_secret")).thenReturn(VALID_CONTACT_KEY_A.removePrefix("pubky")) + whenever(sharedPubkyClient.ringCredential(VALID_SELF_KEY.removePrefix("pubky"))) + .thenReturn(Result.success("ring_secret")) + clearInvocations(keychain) + + assertTrue(sut.hasSecretKey()) + verify(keychain, never()).delete(any()) + } + + @Test + fun `authorization uses the matching Ring credential on its first attempt after stale key cleanup`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + stubAdoptedRingSource() + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("stale_secret") + whenever(pubkyService.publicKeyFromSecret("stale_secret")).thenReturn(VALID_CONTACT_KEY_A.removePrefix("pubky")) + whenever(sharedPubkyClient.ringCredential(VALID_SELF_KEY.removePrefix("pubky"))) + .thenReturn(Result.success("ring_secret")) + + assertTrue(sut.hasSecretKey()) + assertTrue(sut.approveAuth("auth", "capabilities", "client").isSuccess) + + verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + verify(pubkyService).approveAuth("auth", "capabilities", "client", "ring_secret") + } + + @Test + fun `authorization invalidates its captured owner when removing a stale managed credential`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("stale_secret") + whenever(pubkyService.publicKeyFromSecret("stale_secret")).thenReturn(VALID_CONTACT_KEY_A.removePrefix("pubky")) + val owner = sut.profileOwner() + val backupVersion = sut.backupStateVersion.value + + assertTrue(sut.approveAuth("auth", "capabilities", "client").isFailure) + + assertFalse(sut.isProfileOwnerCurrent(owner)) assertEquals(backupVersion + 1, sut.backupStateVersion.value) - verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + verify(pubkyService, never()).approveAuth(any(), any(), any(), any()) + } + + @Test + fun `cancelled authorization derivation preserves the managed credential`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("current_secret") + val entered = CompletableDeferred() + whenever(pubkyService.publicKeyFromSecret("current_secret")).doSuspendableAnswer { + entered.complete(Unit) + awaitCancellation() + } + clearInvocations(keychain) + val approval = launch { sut.approveAuth("auth", "capabilities", "client") } + entered.await() + approval.cancelAndJoin() + verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name) } @Test @@ -1202,7 +1549,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `signOut should continue when endpoint cleanup fails`() = test { + fun `signOut retains identity and durable recovery when endpoint cleanup fails`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) settingsFlow.value = SettingsData( hasConfirmedPublicPaykitEndpoints = true, @@ -1215,34 +1562,34 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.signOut() - assertTrue(result.isSuccess) - assertNull(sut.publicKey.value) - assertFalse(sut.isAuthenticated.value) - assertTrue(settingsFlow.value.publicPaykitCleanupPending) - assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertEquals("", settingsFlow.value.publicPaykitBolt11) - assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) - assertEquals(0, settingsFlow.value.publicPaykitBolt11ExpiresAtMillis) - verifyBlocking(pubkyService) { signOut() } - verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + assertTrue(result.isFailure) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + assertNotNull(profileRemovalStore.pendingAttempt()) + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertEquals("lnbc1old", settingsFlow.value.publicPaykitBolt11) + assertEquals("010203", settingsFlow.value.publicPaykitBolt11PaymentHash) + assertEquals(123L, settingsFlow.value.publicPaykitBolt11ExpiresAtMillis) + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } @Test - fun `signOut should keep cleanup pending when private-only endpoint cleanup fails`() = test { + fun `signOut preserves private sharing and pending removal on endpoint failure`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) whenever(pubkyService.removeBitkitPaymentEndpoints()).thenAnswer { throw TestAppError("Cleanup failed") } val result = sut.signOut() - assertTrue(result.isSuccess) - assertNull(sut.publicKey.value) - assertFalse(sut.isAuthenticated.value) - assertTrue(settingsFlow.value.publicPaykitCleanupPending) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verifyBlocking(pubkyService) { signOut() } - verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + assertTrue(result.isFailure) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + assertNotNull(profileRemovalStore.pendingAttempt()) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } @Test @@ -1338,7 +1685,7 @@ class PubkyRepoTest : BaseUnitTest() { val screenLookup = async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } val deletionStarted = CompletableDeferred() val finishDeletion = CompletableDeferred() - whenever(pubkyService.contactRecords()).doSuspendableAnswer { + whenever(pubkyService.removeContacts(records.map { it.publicKey })).doSuspendableAnswer { deletionStarted.complete(Unit) finishDeletion.await() records @@ -1381,7 +1728,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertEquals(listOf(VALID_CONTACT_KEY_B), sut.contacts.value.map { it.publicKey }) assertEquals(setOf(VALID_CONTACT_KEY_B), stored.contactProfileOverrides.keys) - verify(pubkyService, never()).deletePaykitProfile() + verify(pubkyService, never()).deletePaykitProfile(any()) verify(pubkyService, never()).signOut() } @@ -1397,7 +1744,7 @@ class PubkyRepoTest : BaseUnitTest() { assertSame(error, result.exceptionOrNull()) verify(pubkyStore, never()).update(any()) verify(pubkyService, never()).removeContacts(any()) - verify(pubkyService, never()).deletePaykitProfile() + verify(pubkyService, never()).deletePaykitProfile(any()) verify(pubkyService, never()).signOut() } @@ -1417,7 +1764,7 @@ class PubkyRepoTest : BaseUnitTest() { assertSame(error, result.exceptionOrNull()) assertEquals(listOf(VALID_CONTACT_KEY_A), sut.contacts.value.map { it.publicKey }) verify(pubkyStore, never()).update(any()) - verify(pubkyService, never()).deletePaykitProfile() + verify(pubkyService, never()).deletePaykitProfile(any()) verify(pubkyService, never()).signOut() } @@ -1429,7 +1776,7 @@ class PubkyRepoTest : BaseUnitTest() { sharesPrivatePaykitEndpoints = true, ) val error = TestAppError("Delete failed: HTTP 404 Not Found at session endpoint") - whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw error } + whenever(pubkyService.deletePaykitProfile(any())).thenAnswer { throw error } val result = sut.deleteProfile() @@ -1441,7 +1788,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `deleteProfile should fail when signOut fails`() = test { + fun `deleteProfile retains local evidence when revocation is uncertain`() = test { authenticateForTesting() settingsFlow.value = SettingsData( sharesPublicPaykitEndpoints = true, @@ -1453,9 +1800,10 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.deleteProfile() assertTrue(result.isFailure) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertTrue(settingsFlow.value.publicPaykitCleanupPending) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertNotNull(profileRemovalStore.pendingAttempt()) + verify(pubkyService, never()).forgetLocalProfileAccess() } @Test @@ -1465,12 +1813,14 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) val uncertainResponse = TestAppError("Connection closed after profile deletion") - whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw uncertainResponse }.thenReturn(Unit) + whenever(pubkyService.deletePaykitProfile(any())).thenAnswer { + throw uncertainResponse + }.thenReturn(Unit) val result = sut.deleteProfile() assertSame(uncertainResponse, result.exceptionOrNull()) - verify(pubkyService).deletePaykitProfile() + verify(pubkyService).deletePaykitProfile(any()) verify(pubkyService, never()).signIn(any()) verify(pubkyService, never()).signOut() } @@ -1484,25 +1834,26 @@ class PubkyRepoTest : BaseUnitTest() { val deletionStarted = CompletableDeferred() val finishDeletion = CompletableDeferred() val uncertainResponse = TestAppError("Connection closed after profile deletion") - whenever(pubkyService.deletePaykitProfile()).doSuspendableAnswer { + whenever(pubkyService.deletePaykitProfile(any())).doSuspendableAnswer { deletionStarted.complete(Unit) withContext(NonCancellable) { finishDeletion.await() } throw uncertainResponse } val deletion = async { sut.deleteProfile() } deletionStarted.await() - sut.signOut().getOrThrow() + assertTrue(sut.signOut().isFailure) finishDeletion.complete(Unit) assertSame(uncertainResponse, deletion.await().exceptionOrNull()) + sut.signOut().getOrThrow() assertNull(sut.publicKey.value) assertFalse(sut.isAuthenticated.value) verify(pubkyService, never()).signIn(any()) - verify(pubkyService).deletePaykitProfile() + verify(pubkyService).deletePaykitProfile(any()) } @Test - fun `forgetUnrestoredIdentity clears an expired imported grant without remote calls`() = test { + fun `local disconnect clears an expired imported grant without remote calls`() = test { sut.awaitInitialization() var session: String? = "expired_session" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session } @@ -1518,15 +1869,15 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.hasIdentity()) assertNull(sut.profile.value) assertTrue(sut.contacts.value.isEmpty()) - verify(pubkyService).forgetSessionAccess() + verify(pubkyService).forgetLocalProfileAccess() verify(pubkyService, never()).signOut() verify(pubkyService, never()).removeBitkitPaymentEndpoints() - verify(pubkyService, never()).deletePaykitProfile() + verify(pubkyService, never()).deletePaykitProfile(any()) verify(pubkyStore).reset() } @Test - fun `forgetUnrestoredIdentity preserves identity restored while waiting for auth lock`() = test { + fun `local disconnect rejects an identity whose restoration is still admitted`() = test { sut.awaitInitialization() val importStarted = CompletableDeferred() val finishImport = CompletableDeferred() @@ -1539,13 +1890,13 @@ class PubkyRepoTest : BaseUnitTest() { val initialize = async { sut.initialize() } importStarted.await() val disconnect = async(start = CoroutineStart.UNDISPATCHED) { sut.forgetUnrestoredIdentity() } - assertFalse(disconnect.isCompleted) + assertTrue(disconnect.isCompleted) finishImport.complete(VALID_SELF_KEY) initialize.await() - assertEquals(false, disconnect.await().getOrThrow()) + assertSame(PubkyIdentityBusy, disconnect.await().exceptionOrNull()) assertEquals(VALID_SELF_KEY, sut.publicKey.value) - verify(pubkyService, never()).forgetSessionAccess() + verify(pubkyService, never()).forgetLocalProfileAccess() verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name) } @@ -1561,16 +1912,16 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.signOut() assertTrue(result.isFailure) - verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + verifyBlocking(pubkyService, never()) { forgetLocalProfileAccess() } verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } assertTrue(sut.isAuthenticated.value) assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertTrue(settingsFlow.value.publicPaykitCleanupPending) + assertNotNull(profileRemovalStore.pendingAttempt()) } @Test - fun `signOut should finish clearing state after caller cancellation`() = test { + fun `cancelled unacknowledged signOut retains credentials and durable recovery`() = test { val revocationStarted = CompletableDeferred() val finishRevocation = CompletableDeferred() authenticateForTesting() @@ -1585,9 +1936,10 @@ class PubkyRepoTest : BaseUnitTest() { finishRevocation.complete(Unit) result.join() - assertFalse(sut.isAuthenticated.value) - assertNull(sut.publicKey.value) - verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + assertTrue(sut.isAuthenticated.value) + assertNotNull(sut.publicKey.value) + assertNotNull(profileRemovalStore.pendingAttempt()) + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } @Test @@ -4487,17 +4839,20 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `a sign-in taken while a sign-out resets the store ends once the identity is restored`() = test { + fun `signOut ends old sign-in before durable reset and later restoration cannot revive it`() = test { val store = stubGatedPubkyStore() authenticateForTesting(publicKey = VALID_SELF_KEY) - var signInDuringSignOut: PubkySignIn? = null + val signIn = checkNotNull(sut.currentSignIn()) + var resetObserved = false whenever(pubkyStore.reset()).thenAnswer { - signInDuringSignOut = sut.currentSignIn() + assertNull(sut.currentSignIn()) + assertFalse(sut.isCurrent(signIn)) + resetObserved = true store.data = PubkyStoreData() Unit } assertTrue(sut.signOut().isSuccess) - val signIn = checkNotNull(signInDuringSignOut) + assertTrue(resetObserved) authenticateForTesting(publicKey = VALID_SELF_KEY) val result = sut.updateContact(signIn, VALID_CONTACT_KEY_A, "Alice", "", null, emptyList(), listOf("Friend")) @@ -4749,7 +5104,9 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") val records = listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved")) whenever(pubkyService.removeContacts(records.map { it.publicKey })).thenReturn(records) - whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Offline") } + whenever(pubkyService.deletePaykitProfile(any())).thenAnswer { + throw TestAppError("Offline") + } val newRead = createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice", "New")) val oldRead = holdScreenLookup(VALID_CONTACT_KEY_A, laterRead = newRead) val deletionStarted = CompletableDeferred() diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 2874cd3c9d..f0fcdc9bcc 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -27,6 +27,7 @@ import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -36,6 +37,7 @@ import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.ext.create @@ -48,6 +50,7 @@ import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.util.UUID import javax.inject.Provider import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -63,6 +66,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { private val requestRepo = mock() private val reservations = mock() private val privateCacheStore = mock() + private val profileRemovalStore = ProfileRemovalStore.inMemoryForTesting() private val cacheStore = mock() private val cacheData = MutableStateFlow(AppCacheData(onchainAddress = "wallet-address")) private val settingsStore = mock() @@ -74,6 +78,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { cacheStore = privateCacheStore, addressReservationRepo = Provider { reservations }, paymentRequestRepo = Provider { requestRepo }, + profileRemovalStore = profileRemovalStore, ) } private val sut by lazy { @@ -86,6 +91,28 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { private var activityReads = 0 private var detailReads = 0 + @Test + fun `backfill drops a completed recovery generation and retries the current snapshot`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + val id = UUID.randomUUID().toString() + whenever(reservations.contactPublicKeyForReservedAddress(any())).doSuspendableAnswer { + profileRemovalStore.withAccess(id) { + profileRemovalStore.begin(id) + profileRemovalStore.complete(id) + } + null + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenReturn(null) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + @Test fun `backfill matches receiving address in outputs and preserves all other onchain metadata`() = coreTest { val original = onchain(address = "request-address") diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt index d5c96eab32..258c9f8092 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt @@ -3,11 +3,16 @@ package to.bitkit.services import com.synonym.paykit.PaykitException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Test import to.bitkit.services.PaykitSdkOperationLock.Priority import kotlin.coroutines.cancellation.CancellationException @@ -18,6 +23,182 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PaykitSdkOperationLockTest { + @Test + fun `admitted outer mutex holder cannot enqueue behind a wipe draining that mutex`() = runTest { + val admitted = CompletableDeferred() + val continueAcquire = CompletableDeferred() + val outerMutex = Mutex() + var firstCheck = true + val lock = PaykitSdkOperationLock { + if (firstCheck) { + firstCheck = false + admitted.complete(Unit) + continueAcquire.await() + } + } + val oldOperation = async { + outerMutex.withLock { + assertFailsWith { lock.withLock { error("stale operation executed") } } + } + } + admitted.await() + val wipe = async { + lock.withWalletWipe { + continueAcquire.complete(Unit) + outerMutex.withLock { } + } + } + withTimeout(1_000) { + oldOperation.await() + wipe.await() + } + } + + @Test + fun `queued foreign work cannot resume after a complete removal`() = runTest { + val lock = PaykitSdkOperationLock() + val release = CompletableDeferred() + val active = launch { lock.withLock { release.await() } } + runCurrent() + val removal = launch { lock.withProfileRemoval({ true }) {} } + runCurrent() + var mutations = 0 + val queued = async { + assertFailsWith { lock.withLock { mutations++ } } + } + runCurrent() + release.complete(Unit) + active.join() + removal.join() + queued.await() + assertEquals(0, mutations) + lock.withLock { mutations++ } + assertEquals(1, mutations) + } + + @Test + fun `profile removal drains preceding work and excludes foreign mutations until finalization`() = runTest { + val lock = PaykitSdkOperationLock() + val release = CompletableDeferred() + val finishRemoval = CompletableDeferred() + val events = mutableListOf() + lateinit var oldRemoval: PaykitSdkOperationLock.ProfileRemoval + val preceding = launch { + lock.withLock { + release.await() + events += "preceding" + } + } + runCurrent() + val removal = launch { + lock.withProfileRemoval({ true }) { owner -> + oldRemoval = owner + lock.withLock { events += "owned" } + assertFailsWith { lock.withProfileRemoval({ true }) {} } + assertFailsWith { lock.withWalletWipe {} } + finishRemoval.await() + events += "finalized" + } + } + runCurrent() + assertTrue(events.isEmpty()) + release.complete(Unit) + preceding.join() + runCurrent() + assertEquals(listOf("preceding", "owned"), events) + assertFailsWith { lock.withLock { events += "foreign" } } + assertFailsWith { lock.withProfileRemoval({ true }) {} } + assertEquals("public", lock.withoutLock { "public" }) + assertEquals("handle", lock.withPublicReadInitialization { "handle" }) + finishRemoval.complete(Unit) + removal.join() + assertFailsWith { withContext(oldRemoval) { lock.withLock {} } } + lock.withLock { events += "fresh" } + assertEquals(listOf("preceding", "owned", "finalized", "fresh"), events) + } + + @Test + fun `wipe revokes profile removal and drains local finalization before taking SDK barrier`() = runTest { + val lock = PaykitSdkOperationLock() + val releaseCleanup = CompletableDeferred() + val events = mutableListOf() + val removal = launch { + lock.withProfileRemoval({ true }) { + try { + lock.withLock { events += "native" } + CompletableDeferred().await() + } finally { + withContext(NonCancellable) { + events += "cleanup started" + releaseCleanup.await() + assertFailsWith { lock.checkProfileRemoval() } + events += "cleanup drained" + } + } + } + } + runCurrent() + val wipe = launch { lock.withWalletWipe { lock.withLock { events += "wipe" } } } + runCurrent() + assertEquals(listOf("native", "cleanup started"), events) + assertFalse(wipe.isCompleted) + assertFailsWith { lock.withLock {} } + releaseCleanup.complete(Unit) + removal.join() + wipe.join() + assertEquals(listOf("native", "cleanup started", "cleanup drained", "wipe"), events) + lock.withLock {} + } + + @Test + fun `changed owner rejects the next mutation after a suspended read`() = runTest { + val lock = PaykitSdkOperationLock() + val read = CompletableDeferred() + var current = true + var mutations = 0 + val removal = async { + assertFailsWith { + lock.withProfileRemoval({ current }) { + lock.withLock { + read.await() + lock.checkProfileRemoval() + mutations++ + } + } + } + } + runCurrent() + current = false + read.complete(Unit) + assertEquals("profile_removal_obsolete", removal.await().code) + assertEquals(0, mutations) + lock.withLock {} + } + + @Test + fun `replacing coroutine Job cannot admit a cancelled removal mutation`() = runTest { + val lock = PaykitSdkOperationLock() + val release = CompletableDeferred() + var rejected = false + val removal = launch { + lock.withProfileRemoval({ true }) { + withContext(NonCancellable) { + release.await() + assertFailsWith { lock.withLock { error("cancelled mutation ran") } } + rejected = true + } + } + } + runCurrent() + removal.cancel() + assertFalse(removal.isCompleted) + assertFailsWith { lock.withLock {} } + release.complete(Unit) + removal.join() + assertTrue(rejected) + lock.withLock {} + } + @Test fun `interactive work overtakes only queued publication with bounded fairness`() = runTest { val lock = PaykitSdkOperationLock() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index f4056f8672..85123ce5d2 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -15,6 +15,7 @@ import com.synonym.paykit.PaykitAppCapabilities import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey +import com.synonym.paykit.PaykitProfileRecord import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentPayload @@ -47,6 +48,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation @@ -59,6 +61,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Test import org.mockito.Mockito.mockConstruction import org.mockito.Mockito.mockStatic @@ -76,6 +80,8 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue +import to.bitkit.data.ProfileRemovalPending +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData @@ -96,6 +102,7 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.forEachCase import to.bitkit.utils.AppError +import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -110,6 +117,442 @@ import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class PaykitSdkServiceTest { + @Test + fun `recovery admission lets a direct SDK wipe revoke an idle removal before waiting on publication`() = runTest { + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { mock() } + val removal = launch { service.withProfileRemoval({ true }) { awaitCancellation() } } + runCurrent() + var entered = false + try { + withTimeout(1_000) { service.withWalletWipe { entered = true } } + assertTrue(entered) + assertTrue(removal.isCompleted) + } finally { + removal.cancel() + removal.join() + } + } + + @Test + fun `profile removal recovery rejects bootstrap publication before loading retained keys`() = runTest { + val pending = ProfileRemovalStore.inMemoryForTesting() + val id = UUID.randomUUID().toString() + pending.withAccess(id) { pending.begin(id) } + val sdk = mock() + val bootstrap = mock() + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + settingsStore = mock(), + profileRemovalStore = pending, + ) { sdk } + assertFailsWith { service.republishIdentityIfNeeded(RING_PUBKY) } + assertFailsWith { service.hasIdentityRecord(RING_PUBKY) } + verifyNoInteractions(bootstrap) + verify(sdk, never()).initialize() + } + + @Test + fun `profile removal recovery drains admitted publication while ordinary public reads continue`() = runTest { + val sdk = mock() + val bootstrap = mock() + val published = CompletableDeferred() + whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { published.await() } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), + ) { sdk } + val publishing = async { service.republishIdentityIfNeeded(RING_PUBKY) } + runCurrent() + var removalEntered = false + val removal = async { service.withProfileRemoval({ true }) { removalEntered = true } } + try { + runCurrent() + assertFalse(removalEntered) + assertNull(service.fetchPubkyProfile(RING_PUBKY)) + } finally { + published.complete(true) + publishing.await() + removal.await() + } + assertTrue(removalEntered) + } + + @Test + fun `profile recovery releases public reads already awaiting cold setup`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock(), platformInitializer = {}, settingsStore = mock()) { sdk } + val reading = async { withTimeout(1_000) { service.fetchPubkyProfile(RING_PUBKY) } } + runCurrent() + service.preparePublicProfileRecovery() + assertNull(reading.await()) + verify(sdk, never()).initialize() + } + + @Test + fun `profile recovery exposes platform failure to waiters and permits explicit setup retry`() = runTest { + val sdk = mock() + var failed = true + val failure = IllegalStateException("Platform unavailable") + val service = PaykitSdkService( + mock(), + mock(), + mock(), + platformInitializer = { if (failed) throw failure }, + settingsStore = mock(), + ) { sdk } + assertSame(failure, assertFailsWith { service.preparePublicProfileRecovery() }) + val waiterFailure = assertFailsWith { + withTimeout(1_000) { service.fetchPubkyProfile(RING_PUBKY) } + } + assertEquals(failure.javaClass, waiterFailure.javaClass) + assertTrue(generateSequence(waiterFailure) { it.cause }.any { it === failure }) + failed = false + service.preparePublicProfileRecovery() + assertNull(service.fetchPubkyProfile(RING_PUBKY)) + verify(sdk, never()).initialize() + } + + @Test + fun `profile recovery promotion drains admitted native work before resetting runtime`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + whenever(sdk.contactRecords()).doSuspendableAnswer { + release.await() + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), platformInitializer = {}, settingsStore = mock()) { sdk } + service.preparePublicProfileRecovery() + val read = async { service.contactRecords() } + runCurrent() + val before = service.runtimeGeneration + val initialize = async { service.initialize() } + try { + runCurrent() + assertEquals(before, service.runtimeGeneration) + verify(sdk, never()).initialize() + } finally { + release.complete(Unit) + read.await() + initialize.await() + } + } + + @Test + fun `profile recovery permits wipe drainage without activating retained credentials`() = runTest { + val pending = ProfileRemovalStore.inMemoryForTesting() + val id = UUID.randomUUID().toString() + pending.withAccess(id) { pending.begin(id) } + val sdk = mock() + val service = PaykitSdkService( + mock(), + mock(), + mock(), + platformInitializer = {}, + settingsStore = mock(), + profileRemovalStore = pending, + ) { sdk } + withTimeout(1_000) { + service.withWalletWipe { assertNull(service.fetchPubkyProfile(RING_PUBKY)) } + } + verify(sdk, never()).initialize() + assertEquals(id, pending.pendingAttempt()) + } + + @Test + fun `pending restart permits public reads and strict local disconnect without activation`() = runTest { + val pending = ProfileRemovalStore.inMemoryForTesting() + val id = UUID.randomUUID().toString() + pending.withAccess(id) { pending.begin(id) } + val sdk = mock() + val keychain = mock() + val blocking = mock() + whenever(keychain.accessBlocking(any())).doAnswer { + it.getArgument Any?>(0).invoke(blocking) + } + var platformSetups = 0 + val service = PaykitSdkService( + mock(), + keychain, + mock(), + platformInitializer = { platformSetups++ }, + settingsStore = mock(), + profileRemovalStore = pending, + ) { sdk } + assertFailsWith { service.initialize() } + assertEquals(0, platformSetups) + service.preparePublicProfileRecovery() + assertEquals(1, platformSetups) + assertNull(service.fetchPubkyProfile(RING_PUBKY)) + assertFailsWith { service.contactRecords() } + assertFailsWith { service.forgetLocalProfileAccess() } + val failure = IllegalStateException("Credential storage unavailable") + org.mockito.kotlin.doThrow(failure).whenever(blocking).delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) + pending.withAccess(id) { + service.withProfileRemoval({ true }) { + assertFailsWith { service.forgetLocalProfileAccess() } + } + } + assertEquals(id, pending.pendingAttempt()) + pending.withAccess(id) { assertFailsWith { service.initialize() } } + verify(sdk, never()).initialize() + verify(sdk, never()).signOut() + verify(sdk, never()).forgetSessionAccess() + org.mockito.kotlin.doNothing().whenever(blocking).delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) + pending.withAccess(id) { + service.withProfileRemoval({ true }) { service.forgetLocalProfileAccess() } + pending.complete(id) + } + verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name) + verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + service.initialize() + verify(sdk).initialize() + assertEquals(1, platformSetups) + } + + @Test + fun `cancellation after confirmed profile deletion retains backup dirtiness`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val profile = mock() + whenever(profile.revision).thenReturn("confirmed") + whenever(sdk.fetchPaykitProfile(RING_PUBKY)).thenReturn(profile) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val release = CompletableDeferred() + whenever(sdk.deletePaykitProfile("confirmed")).doSuspendableAnswer { release.await() } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val snapshot = service.captureProfileDeletion(RING_PUBKY) { true } + val before = service.backupStateVersion.value + val removal = launch { service.withProfileRemoval({ true }) { service.deletePaykitProfile(snapshot) } } + runCurrent() + removal.cancel() + assertFalse(removal.isCompleted) + release.complete(Unit) + removal.join() + verify(sdk).deletePaykitProfile("confirmed") + assertTrue(service.backupStateVersion.value > before) + } + + @Test + fun `profile deletion uses the confirmed revision without fetching a newer profile`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val profile = mock() + whenever(profile.revision).thenReturn("confirmed") + whenever(sdk.fetchPaykitProfile(RING_PUBKY)).thenReturn(profile) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val snapshot = service.captureProfileDeletion("pubky$RING_PUBKY") { true } + whenever(profile.revision).thenReturn("later-edit") + val uncertain = IllegalStateException("Response lost") + whenever(sdk.deletePaykitProfile("confirmed")).thenThrow(uncertain) + + val result = runSuspendCatching { service.deletePaykitProfile(snapshot) } + + assertSame(uncertain, result.exceptionOrNull()) + verify(sdk).deletePaykitProfile("confirmed") + verify(sdk, times(1)).fetchPaykitProfile(RING_PUBKY) + verify(sdk, never()).deletePaykitProfile("later-edit") + } + + @Test + fun `profile reconciliation rejects later profile and contact changes without mutations`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val profile = mock() + whenever(profile.revision).thenReturn("original") + whenever(sdk.fetchPaykitProfile(RING_PUBKY)).thenReturn(profile) + val contact = mock() + whenever(sdk.contactRecords()).thenReturn(listOf(contact)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val snapshot = service.captureProfileDeletion(RING_PUBKY) { true } + whenever(profile.revision).thenReturn("changed") + assertFailsWith { service.validateProfileDeletion(snapshot) } + whenever(profile.revision).thenReturn("original") + whenever(sdk.contactRecords()).thenReturn(emptyList()) + assertFailsWith { service.validateProfileDeletion(snapshot) } + verify(sdk, never()).deletePaykitProfile(any()) + verify(sdk, never()).removeContactsAndBlockPeers(any()) + } + + @Test + fun `profile capture rejects identity replacement during its read`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + var current = true + whenever(sdk.contactRecords()).thenAnswer { + current = false + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + assertFailsWith { service.captureProfileDeletion(RING_PUBKY) { current } } + verify(sdk, never()).deletePaykitProfile(any()) + } + + @Test + fun `confirmed missing profile does not authorize deleting a subsequently created profile`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val snapshot = service.captureProfileDeletion(RING_PUBKY) { true } + whenever(sdk.fetchPaykitProfile(RING_PUBKY)).thenReturn(mock()) + assertFailsWith { service.deletePaykitProfile(snapshot) } + verify(sdk, never()).deletePaykitProfile(any()) + } + + @Test + fun `profile removal rejects foreign reset but permits cold public lookup`() = runTest { + val sdk = mock() + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val release = CompletableDeferred() + var handles = 0 + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { + handles++ + sdk + } + val removal = launch { service.withProfileRemoval({ true }) { release.await() } } + runCurrent() + assertEquals(0, handles) + assertNull(service.fetchPubkyProfile(RING_PUBKY)) + assertEquals(1, handles) + assertFailsWith { service.signOut() } + assertFailsWith { service.forgetSessionAccess() } + assertFailsWith { service.clearState() } + verify(sdk, never()).signOut() + verify(sdk, never()).forgetSessionAccess() + release.complete(Unit) + removal.join() + assertEquals(emptyList(), service.contactRecords()) + assertEquals(1, handles) + } + + @Test + fun `cancelled removal does not delete after an awaited profile read with replaced Job`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val release = CompletableDeferred() + val profile = mock() + whenever(profile.revision).thenReturn("confirmed-profile-revision") + whenever(sdk.fetchPaykitProfile(RING_PUBKY)).doSuspendableAnswer { + release.await() + profile + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + var rejected = false + val removal = launch { + service.withProfileRemoval({ true }) { + withContext(NonCancellable) { + assertFailsWith { service.deletePaykitProfile() } + rejected = true + } + } + } + runCurrent() + removal.cancel() + assertFalse(removal.isCompleted) + assertFailsWith { service.clearState() } + release.complete(Unit) + removal.join() + assertTrue(rejected) + verify(sdk, never()).deletePaykitProfile(any()) + } + + @Test + fun `wipe waits for admitted removal mutation and local finalization before resetting SDK`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val profile = mock() + whenever(profile.revision).thenReturn("confirmed-profile-revision") + whenever(sdk.fetchPaykitProfile(RING_PUBKY)).thenReturn(profile) + val releaseNative = CompletableDeferred() + val releaseLocal = CompletableDeferred() + val events = mutableListOf() + whenever(sdk.deletePaykitProfile(any())).doSuspendableAnswer { + events += "native admitted" + releaseNative.await() + events += "native drained" + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val removal = launch { + service.withProfileRemoval({ true }) { + try { + service.deletePaykitProfile() + } finally { + withContext(NonCancellable) { + releaseLocal.await() + events += "local drained" + } + } + } + } + try { + runCurrent() + assertEquals(listOf("native admitted"), events) + val wipe = launch { service.withWalletWipe { events += "wipe" } } + runCurrent() + assertFalse(wipe.isCompleted) + releaseNative.complete(Unit) + runCurrent() + assertEquals(listOf("native admitted", "native drained"), events) + assertFalse(wipe.isCompleted) + releaseLocal.complete(Unit) + removal.join() + wipe.join() + assertEquals(listOf("native admitted", "native drained", "local drained", "wipe"), events) + verify(sdk).deletePaykitProfile("confirmed-profile-revision") + } finally { + releaseNative.complete(Unit) + releaseLocal.complete(Unit) + removal.cancel() + } + } + + @Test + fun `completed removal mutation keeps backup invalidation when original Job cancels bookkeeping`() = runTest { + val sdk = mock() + whenever(sdk.backupStateRevision()).thenReturn("before") + val release = CompletableDeferred() + whenever(sdk.processPendingPrivateMessages()).doSuspendableAnswer { + release.await() + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val removal = launch { + service.withProfileRemoval({ true }) { + withContext(NonCancellable) { + assertFailsWith { service.processPendingPrivateMessages() } + } + } + } + runCurrent() + removal.cancel() + release.complete(Unit) + removal.join() + verify(sdk).processPendingPrivateMessages() + assertEquals(1L, service.backupStateVersion.value) + } + companion object { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val FILE_URI = "pubky://$RING_PUBKY/pub/pubky.app/files/avatar" diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt index 9645c467ea..ba8842ceba 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt @@ -22,10 +22,12 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData @@ -33,16 +35,21 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.sharedpubky.SharedPubkyClient +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTargetCheck +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyContactError +import to.bitkit.repositories.PubkyProfileOwner import to.bitkit.repositories.PubkyRepo import to.bitkit.services.PaykitReadLane import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.usecases.ProfileRemovalCoordinator import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Clock @@ -83,6 +90,7 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { private var heldSave: CompletableDeferred? = null private var heldSaveSucceeds = true + private val removalStore = ProfileRemovalStore.inMemoryForTesting() private lateinit var repo: PubkyRepo @Before @@ -105,6 +113,10 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { } whenever { pubkyService.contactRecords() }.thenReturn(emptyList()) whenever { pubkyService.observedContactRecords(any()) }.doSuspendableAnswer { pubkyService.contactRecords() } + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + check(it.getArgument<() -> Boolean>(0).invoke()) + it.getArgument Unit>(1).invoke() + } whenever { pubkyService.signOut() }.thenAnswer { sdkIdentity = null Unit @@ -127,6 +139,7 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { settingsStore = settingsStore, httpClient = mock(), clock = clock, + profileRemovalStore = removalStore, ) } @@ -272,6 +285,22 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { assertTrue(repo.signOut().isSuccess) } + private suspend fun PubkyRepo.signOut(): Result = runSuspendCatching { + val privateRepo = mock() + val sharing = mock() + whenever { sharing.withProfileRemoval(any()) }.doSuspendableAnswer { + it.getArgument PubkyProfileOwner>(0).invoke() + } + whenever { privateRepo.withProfileDeletionPaused(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0) + .invoke(PrivatePaykitRepo.ProfileDeletionPause()) + } + whenever { privateRepo.removePublishedEndpointsForCleanup(any(), eq(true)) }.thenReturn(Result.success(Unit)) + whenever { privateRepo.finishProfileRemoval(any()) }.thenReturn(Result.success(Unit)) + val coordinator = ProfileRemovalCoordinator(this, privateRepo, sharing, removalStore) + coordinator.remove(coordinator.prepareSignOut(profileOwner()).getOrThrow()).getOrThrow() + } + private suspend fun fakeSdkSave( publicKey: String, expectedIdentity: String?, 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 30f48762ae..627e4ffd85 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 @@ -4,141 +4,234 @@ import android.content.Context import androidx.lifecycle.viewModelScope import app.cash.turbine.test import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test -import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq -import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink -import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileOwner import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class EditProfileViewModelTest : BaseUnitTest() { - companion object { - private const val TEST_PUBLIC_KEY = "pubkyalice" - } - + companion object { private const val TEST_PUBLIC_KEY = "pubkyalice" } private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() - private val privatePaykitRepo: PrivatePaykitRepo = mock() + private val fixture = ProfileRemovalUiFixture(pubkyRepo) @Test - fun `disconnect finishes private cleanup after the screen is closed`() = test { + fun `confirmation is required and consumed once`() = test { val sut = createSut() - val forget = CompletableDeferred>() - whenever(pubkyRepo.forgetUnrestoredIdentity()).doSuspendableAnswer { forget.await() } - advanceUntilIdle() - - sut.disconnectProfile() advanceUntilIdle() - assertTrue(sut.uiState.value.isDeleting) - assertFalse(sut.uiState.value.isSaving) - assertTrue(sut.uiState.value.isBusy) - - sut.disconnectProfile() sut.deleteProfile() - sut.save() + advanceUntilIdle() + verify(pubkyRepo, never()).deleteProfile(any()) sut.showDeleteConfirmation() advanceUntilIdle() - verify(pubkyRepo, times(1)).forgetUnrestoredIdentity() - verify(pubkyRepo, never()).deleteProfile() - verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) - assertFalse(sut.uiState.value.showDeleteDialog) + assertTrue(sut.uiState.value.showDeleteDialog) + sut.effects.test { + sut.deleteProfile() + sut.deleteProfile() + advanceUntilIdle() + val effect = assertIs(awaitItem()) + assertTrue(sut.isRemovalCurrent(effect)) + fixture.current = PubkyProfileOwner(PubkySignIn(TEST_PUBLIC_KEY, 3), 3) + assertFalse(sut.isRemovalCurrent(effect)) + expectNoEvents() + } + verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) + assertNull(fixture.store.pendingAttempt()) + } - sut.viewModelScope.cancel() - forget.complete(Result.success(true)) + @Test + fun `uncertain deletion review reads state and requires fresh confirmation`() = test { + val sut = createSut() + whenever(pubkyRepo.deleteProfile(fixture.deletion)).thenReturn(Result.failure(TestAppError("lost response"))) advanceUntilIdle() - - assertFalse(sut.uiState.value.isDeleting) - assertFalse(sut.uiState.value.isBusy) - verify(privatePaykitRepo).closeAndClear() - verify(pubkyRepo, never()).signOut() + sut.showDeleteConfirmation() + advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertNotNull(fixture.store.pendingAttempt()) + sut.retryDeleteProfile() + advanceUntilIdle() + assertTrue(sut.uiState.value.showDeleteDialog) + verify(pubkyRepo, times(2)).prepareProfileDeletion(fixture.original) + verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) + sut.dismissDeleteDialog() + assertTrue(sut.uiState.value.showDeleteFailureDialog) + sut.deleteProfile() + advanceUntilIdle() + verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) } @Test - fun `disconnect forgets unrestored identity without remote cleanup`() = test { + fun `failure disconnect asks again and never replays remote cleanup`() = test { val sut = createSut() - whenever(pubkyRepo.forgetUnrestoredIdentity()).thenReturn(Result.success(true)) - whenever(privatePaykitRepo.removePublishedEndpointsForCleanup(any())) - .thenReturn(Result.failure(AppError("No session"))) + whenever(pubkyRepo.deleteProfile(fixture.deletion)).thenReturn(Result.failure(TestAppError("lost response"))) advanceUntilIdle() - + sut.showDeleteConfirmation() + advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() + sut.disconnectProfile() + advanceUntilIdle() + assertTrue(sut.uiState.value.showDeleteDialog) + assertTrue(sut.uiState.value.isLocalDisconnect) + verify(pubkyRepo, never()).finishProfileRemovalLocally() sut.effects.test { - sut.disconnectProfile() + sut.deleteProfile() advanceUntilIdle() - assertEquals(EditProfileEffect.DisconnectSuccess, awaitItem()) + assertIs(awaitItem()) } - - assertFalse(sut.uiState.value.isDeleting) - verify(privatePaykitRepo, never()).removePublishedEndpointsForCleanup(any()) - verify(pubkyRepo, never()).signOut() - verify(privatePaykitRepo).closeAndClear() + verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) + verify(pubkyRepo, never()).signOutForProfileRemoval() + verify(fixture.privateRepo, times(1)).removePublishedEndpointsForCleanup(any(), eq(true)) + assertNull(fixture.store.pendingAttempt()) } @Test - fun `failed local disconnect preserves private state`() = test { + fun `failed private cleanup retains marker and stops profile deletion`() = test { val sut = createSut() - whenever(pubkyRepo.forgetUnrestoredIdentity()).thenReturn(Result.failure(AppError("Storage failed"))) + whenever(fixture.privateRepo.removePublishedEndpointsForCleanup(any(), eq(true))) + .thenReturn(Result.failure(TestAppError("pending cleanup"))) + advanceUntilIdle() + sut.showDeleteConfirmation() advanceUntilIdle() + sut.effects.test { + sut.deleteProfile() + advanceUntilIdle() + expectNoEvents() + } + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertNotNull(fixture.store.pendingAttempt()) + verify(pubkyRepo, never()).deleteProfile(any()) + verify(fixture.privateRepo, never()).finishProfileRemoval(any()) + } + @Test + fun `local cleanup failure offers only local completion and retains safety state`() = test { + val sut = createSut() + whenever(fixture.privateRepo.finishProfileRemoval(fixture.pause)) + .thenReturn(Result.failure(TestAppError("disk unavailable"))) + advanceUntilIdle() + sut.showDeleteConfirmation() + advanceUntilIdle() + sut.effects.test { + sut.deleteProfile() + advanceUntilIdle() + expectNoEvents() + } + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertFalse(sut.uiState.value.canReviewRemoval) + assertNotNull(fixture.store.pendingAttempt()) + sut.retryDeleteProfile() + advanceUntilIdle() + assertFalse(sut.uiState.value.showDeleteDialog) sut.disconnectProfile() advanceUntilIdle() - - assertFalse(sut.uiState.value.isDeleting) - verify(privatePaykitRepo, never()).closeAndClear() - verify(pubkyRepo, never()).signOut() + assertTrue(sut.uiState.value.isLocalDisconnect) + assertTrue(sut.uiState.value.showDeleteDialog) } @Test - fun `pending deletion blocks duplicate actions and clears progress on failure`() = test { + fun `cancelled screen preserves pending removal without success`() = test { val sut = createSut() + val cleanup = CompletableDeferred>() + whenever(fixture.privateRepo.removePublishedEndpointsForCleanup(any(), eq(true))) + .doSuspendableAnswer { cleanup.await() } advanceUntilIdle() - val result = CompletableDeferred>() - whenever(pubkyRepo.deleteProfile()).doSuspendableAnswer { result.await() } - sut.showDeleteConfirmation() + advanceUntilIdle() sut.deleteProfile() advanceUntilIdle() assertTrue(sut.uiState.value.isDeleting) - assertFalse(sut.uiState.value.isSaving) - assertTrue(sut.uiState.value.isBusy) + sut.deleteProfile() + sut.disconnectProfile() + sut.save() + advanceUntilIdle() + verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) + sut.viewModelScope.cancel() + advanceUntilIdle() + assertFalse(sut.uiState.value.isBusy) + assertNotNull(fixture.store.pendingAttempt()) + verify(pubkyRepo, never()).deleteProfile(any()) + verify(fixture.privateRepo, never()).finishProfileRemoval(any()) + } + + @Test + fun `replacement identity cannot be removed from an older profile screen`() = test { + val sut = createSut() + advanceUntilIdle() + fixture.current = PubkyProfileOwner(PubkySignIn(TEST_PUBLIC_KEY, 3), 3) + sut.showDeleteConfirmation() + advanceUntilIdle() assertFalse(sut.uiState.value.showDeleteDialog) + verify(pubkyRepo, never()).prepareProfileDeletion(any()) + verify(pubkyRepo, never()).deleteProfile(any()) + } + @Test + fun `dismissal or replacement during preparation cannot reopen confirmation`() = test { + val sut = createSut() + val snapshot = CompletableDeferred>() + whenever(pubkyRepo.prepareProfileDeletion(fixture.original)).doSuspendableAnswer { snapshot.await() } + advanceUntilIdle() + sut.showDeleteConfirmation() + advanceUntilIdle() + assertTrue(sut.uiState.value.isPreparingRemoval) + sut.dismissDeleteDialog() + snapshot.complete(Result.success(fixture.deletion)) + advanceUntilIdle() + assertFalse(sut.uiState.value.showDeleteDialog) sut.deleteProfile() - sut.retryDeleteProfile() - sut.save() - sut.disconnectProfile() + verify(pubkyRepo, never()).deleteProfile(any()) + } + + @Test + fun `delayed Ring lookup does not install a replaced profile`() = test { + val ring = CompletableDeferred() + val sut = createSut(ringCheck = ring) + advanceUntilIdle() + fixture.current = PubkyProfileOwner(PubkySignIn("replacement", 2), 2) + ring.complete(true) + advanceUntilIdle() + assertEquals("", sut.uiState.value.name) sut.showDeleteConfirmation() advanceUntilIdle() - verify(pubkyRepo, times(1)).deleteProfile() - verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) - verify(pubkyRepo, never()).signOut() assertFalse(sut.uiState.value.showDeleteDialog) + } - result.complete(Result.failure(TestAppError("Offline"))) + @Test + fun `managed and Ring identity keep distinct removal copy`() = test { + val sut = createSut(isRingIdentity = true) advanceUntilIdle() - assertFalse(sut.uiState.value.isDeleting) - assertFalse(sut.uiState.value.isBusy) - assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertEquals(ProfileRemoval.UNLINK, sut.uiState.value.removal) + sut.showDeleteConfirmation() + advanceUntilIdle() + assertFalse(sut.uiState.value.isLocalDisconnect) } @Test @@ -161,8 +254,8 @@ class EditProfileViewModelTest : BaseUnitTest() { sut.showDeleteConfirmation() advanceUntilIdle() verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), any(), any()) - verify(pubkyRepo, never()).deleteProfile() - verify(pubkyRepo, never()).forgetUnrestoredIdentity() + verify(pubkyRepo, never()).deleteProfile(any()) + verify(pubkyRepo, never()).prepareProfileDeletion(any()) assertFalse(sut.uiState.value.showDeleteDialog) result.complete(Result.failure(TestAppError("Offline"))) @@ -181,107 +274,6 @@ class EditProfileViewModelTest : BaseUnitTest() { assertEquals("https://updated.example.com", sut.uiState.value.links.first().url) } - @Test - fun `failed private cleanup stops deletion and preserves pending state`() = test { - val sut = createSut() - advanceUntilIdle() - whenever(privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))) - .thenReturn(Result.failure(TestAppError("Withdrawal pending"))) - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) - - sut.effects.test { - sut.deleteProfile() - advanceUntilIdle() - expectNoEvents() - } - - assertTrue(sut.uiState.value.showDeleteFailureDialog) - assertFalse(sut.uiState.value.isDeleting) - verify(pubkyRepo, never()).deleteProfile() - verify(privatePaykitRepo, never()).closeAndClear() - verify(privatePaykitRepo).endProfileDeletion() - } - - @Test - fun `failed local finalization does not report deletion success`() = test { - val sut = createSut() - advanceUntilIdle() - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) - whenever( - privatePaykitRepo.closeAndClear() - ).thenReturn(Result.failure(TestAppError("Local storage unavailable"))) - - sut.effects.test { - sut.deleteProfile() - advanceUntilIdle() - expectNoEvents() - } - - assertFalse(sut.uiState.value.showDeleteFailureDialog) - assertFalse(sut.uiState.value.isDeleting) - verify(pubkyRepo).deleteProfile() - verify(privatePaykitRepo).closeAndClear() - verify(privatePaykitRepo).endProfileDeletion() - } - - @Test - fun `deleteProfile should emit success when repository delete succeeds`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) - - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.deleteProfile() - advanceUntilIdle() - - assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) - } - assertFalse(sut.uiState.value.showDeleteFailureDialog) - inOrder(privatePaykitRepo, pubkyRepo).apply { - verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) - verify(pubkyRepo).deleteProfile() - verify(privatePaykitRepo).closeAndClear() - } - } - - @Test - fun `profile created in Bitkit keeps delete copy`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) - val sut = createSut() - advanceUntilIdle() - - assertEquals(ProfileRemoval.DELETE, sut.uiState.value.removal) - - sut.deleteProfile() - advanceUntilIdle() - verify(context).getString(R.string.profile__delete_success) - verify(context, never()).getString(R.string.profile__unlink_success) - } - - @Test - fun `ring identity is unlinked with the same cleanup`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) - val sut = createSut(isRingIdentity = true) - advanceUntilIdle() - - assertEquals(ProfileRemoval.UNLINK, sut.uiState.value.removal) - - sut.effects.test { - sut.deleteProfile() - advanceUntilIdle() - - assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) - } - verify(context).getString(R.string.profile__unlink_success) - verify(context, never()).getString(R.string.profile__delete_success) - inOrder(privatePaykitRepo, pubkyRepo).apply { - verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) - verify(pubkyRepo).deleteProfile() - verify(privatePaykitRepo).closeAndClear() - } - } - @Test fun `ring identity keeps loading until unlink copy is resolved`() = test { val ringCheck = CompletableDeferred() @@ -298,114 +290,83 @@ class EditProfileViewModelTest : BaseUnitTest() { } @Test - fun `retryDeleteProfile should emit success when repository delete succeeds`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) - + fun `dismissed failure flow cannot reopen after a delayed review`() = test { val sut = createSut() + whenever(pubkyRepo.deleteProfile(fixture.deletion)).thenReturn(Result.failure(TestAppError("lost response"))) advanceUntilIdle() - - sut.effects.test { - sut.retryDeleteProfile() - advanceUntilIdle() - - assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) - } - assertFalse(sut.uiState.value.showDeleteFailureDialog) - inOrder(privatePaykitRepo, pubkyRepo).apply { - verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) - verify(pubkyRepo).deleteProfile() - verify(privatePaykitRepo).closeAndClear() - } - } - - @Test - fun `deleteProfile should show retry dialog when delete still fails`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn( - Result.failure(TestAppError("expired session")), - ) - - val sut = createSut() + sut.showDeleteConfirmation() advanceUntilIdle() - sut.deleteProfile() advanceUntilIdle() - - assertTrue(sut.uiState.value.showDeleteFailureDialog) - assertFalse(sut.uiState.value.isDeleting) - verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) - verify(privatePaykitRepo, never()).closeAndClear() - } - - @Test - fun `disconnectProfile should emit disconnect success`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn( - Result.failure(TestAppError("expired session")), - ) - whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) - - val sut = createSut() + val snapshot = CompletableDeferred>() + whenever(pubkyRepo.prepareProfileDeletion(fixture.original)).doSuspendableAnswer { snapshot.await() } + sut.retryDeleteProfile() advanceUntilIdle() - sut.deleteProfile() + sut.dismissDeleteFailureDialog() + snapshot.complete(Result.success(fixture.deletion)) advanceUntilIdle() - clearInvocations(privatePaykitRepo) - - sut.effects.test { - sut.disconnectProfile() - advanceUntilIdle() - - assertEquals(EditProfileEffect.DisconnectSuccess, awaitItem()) - } + assertFalse(sut.uiState.value.showDeleteDialog) assertFalse(sut.uiState.value.showDeleteFailureDialog) - inOrder(privatePaykitRepo, pubkyRepo).apply { - verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) - verify(pubkyRepo).signOut() - verify(privatePaykitRepo).closeAndClear() - } + verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) } @Test - fun `disconnectProfile stops when private cleanup fails`() = test { + fun `early local-only failure offers disconnect without remote review`() = test { val sut = createSut() - whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } - .thenReturn(Result.failure(TestAppError("cleanup failed"))) - whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.deleteProfile(fixture.deletion)) + .thenReturn(Result.failure(TestAppError("Unknown response"))) advanceUntilIdle() - - sut.disconnectProfile() + sut.showDeleteConfirmation() advanceUntilIdle() - - assertFalse(sut.uiState.value.isDeleting) - verify(pubkyRepo, never()).signOut() - verify(privatePaykitRepo, never()).closeAndClear() - } - - @Test - fun `disconnectProfile preserves local Paykit state when Pubky sign out fails`() = test { - val sut = createSut() - whenever(pubkyRepo.signOut()).thenReturn(Result.failure(TestAppError("sign out failed"))) + sut.deleteProfile() advanceUntilIdle() - sut.disconnectProfile() advanceUntilIdle() - - assertFalse(sut.uiState.value.isDeleting) - verify(privatePaykitRepo, never()).closeAndClear() + whenever { fixture.privateRepo.withProfileDeletionPaused(any()) }.thenAnswer { + throw IllegalStateException("Pause unavailable") + } + sut.deleteProfile() + advanceUntilIdle() + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertFalse(sut.uiState.value.canReviewRemoval) + verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) } @Test - fun `dismissDeleteFailureDialog should hide retry dialog`() = test { - whenever(pubkyRepo.deleteProfile()).thenReturn( - Result.failure(TestAppError("expired session")), - ) - + fun `cancelled local completion retains its owner through a later admission failure`() = test { val sut = createSut() + org.mockito.kotlin.doAnswer { + fixture.current = fixture.ended + throw CancellationException("Cancelled after acknowledged credential removal") + }.whenever(pubkyRepo).finishProfileRemovalLocally() advanceUntilIdle() - sut.deleteProfile() + sut.showDeleteConfirmation() advanceUntilIdle() - - sut.dismissDeleteFailureDialog() - - assertFalse(sut.uiState.value.showDeleteFailureDialog) + sut.effects.test { + sut.deleteProfile() + advanceUntilIdle() + expectNoEvents() + assertNotNull(fixture.store.pendingAttempt()) + verify(fixture.privateRepo, never()).finishProfileRemoval(any()) + sut.disconnectProfile() + advanceUntilIdle() + fixture.admissionFailure = IllegalStateException("Temporarily busy") + sut.deleteProfile() + advanceUntilIdle() + expectNoEvents() + assertNotNull(fixture.store.pendingAttempt()) + fixture.admissionFailure = null + org.mockito.kotlin.doAnswer { fixture.current = fixture.ended }.whenever( + pubkyRepo + ).finishProfileRemovalLocally() + sut.disconnectProfile() + advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() + assertIs(awaitItem()) + assertNull(fixture.store.pendingAttempt()) + } + verify(pubkyRepo, times(1)).signOutForProfileRemoval() } private fun createSut( @@ -418,19 +379,14 @@ class EditProfileViewModelTest : BaseUnitTest() { } else { whenever { pubkyRepo.isRingAdoptedIdentity() }.thenReturn(isRingIdentity) } - whenever { pubkyRepo.forgetUnrestoredIdentity() }.thenReturn(Result.success(false)) + fixture.setup() whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(createProfile())) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY)) - whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) return EditProfileViewModel( context = context, pubkyRepo = pubkyRepo, - privatePaykitRepo = privatePaykitRepo, + removalCoordinator = fixture.coordinator, ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileRemovalUiFixture.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileRemovalUiFixture.kt new file mode 100644 index 0000000000..7989b7d3f3 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileRemovalUiFixture.kt @@ -0,0 +1,60 @@ +package to.bitkit.ui.screens.profile + +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.ProfileRemovalStore +import to.bitkit.ext.runSuspendCatching +import to.bitkit.repositories.ContactPaymentSettingsRepo +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileDeletion +import to.bitkit.repositories.PubkyProfileOwner +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn +import to.bitkit.services.PaykitProfileDeletionSnapshot +import to.bitkit.usecases.ProfileRemovalCoordinator + +/** Real orchestration and durable marker, with controllable local/native repository boundaries. */ +internal class ProfileRemovalUiFixture(val pubky: PubkyRepo) { + val privateRepo = mock() + private val sharing = mock() + val store = ProfileRemovalStore.inMemoryForTesting() + val original = PubkyProfileOwner(PubkySignIn("pubkyalice", 1), 1) + val ended = PubkyProfileOwner(null, 2) + var current = original + var admissionFailure: Throwable? = null + val pause = PrivatePaykitRepo.ProfileDeletionPause() + val deletion = + PubkyProfileDeletion(original, 1, PaykitProfileDeletionSnapshot("pubkyalice", "revision", emptyList())) + val coordinator = ProfileRemovalCoordinator(pubky, privateRepo, sharing, store) + + fun setup() { + whenever(pubky.profileOwner()).thenAnswer { current } + whenever(pubky.isProfileOwnerCurrent(any())).thenAnswer { it.getArgument(0) == current } + whenever { pubky.prepareProfileDeletion(original) }.thenReturn(Result.success(deletion)) + whenever { sharing.withProfileRemoval(any()) }.doSuspendableAnswer { + it.getArgument PubkyProfileOwner>(0).invoke() + } + whenever { pubky.performProfileRemoval(any(), any(), any()) }.doSuspendableAnswer { + val owner = it.getArgument(0) + val id = it.getArgument(1) + val operation = it.getArgument Unit>(2) + runSuspendCatching { + admissionFailure?.let { error -> throw error } + check(owner == current) + store.withAccess(id) { operation() } + current + } + } + whenever { privateRepo.withProfileDeletionPaused(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0).invoke(pause) + } + whenever { privateRepo.removePublishedEndpointsForCleanup(any(), eq(true)) }.thenReturn(Result.success(Unit)) + whenever { pubky.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) + whenever { pubky.deleteProfile(deletion) }.thenReturn(Result.success(Unit)) + whenever { pubky.finishProfileRemovalLocally() }.thenAnswer { current = ended } + whenever { privateRepo.finishProfileRemoval(pause) }.thenReturn(Result.success(Unit)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 2a45e49029..70211e7042 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -1,11 +1,10 @@ package to.bitkit.ui.screens.profile import android.content.Context -import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test @@ -22,13 +21,16 @@ import org.mockito.kotlin.whenever import to.bitkit.data.PubkyCachedProfile import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink -import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileOwner import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn import to.bitkit.test.BaseUnitTest import to.bitkit.test.forEachCase import to.bitkit.utils.AppError +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -37,7 +39,7 @@ import kotlin.test.assertTrue class ProfileViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() - private val privatePaykitRepo: PrivatePaykitRepo = mock() + private val fixture = ProfileRemovalUiFixture(pubkyRepo) @Test fun `saved public profile is shown without enabling edits while restoration waits`() = test { @@ -232,57 +234,6 @@ class ProfileViewModelTest : BaseUnitTest() { } } - @Test - fun `disconnect finishes private cleanup after the screen is closed`() = test { - val sut = createSut() - val forget = CompletableDeferred>() - whenever(pubkyRepo.forgetUnrestoredIdentity()).doSuspendableAnswer { forget.await() } - advanceUntilIdle() - - sut.signOut() - advanceUntilIdle() - sut.viewModelScope.cancel() - forget.complete(Result.success(true)) - advanceUntilIdle() - - verify(privatePaykitRepo).closeAndClear() - verify(pubkyRepo, never()).signOut() - } - - @Test - fun `disconnect forgets unrestored identity without remote cleanup`() = test { - val sut = createSut() - whenever(pubkyRepo.forgetUnrestoredIdentity()).thenReturn(Result.success(true)) - whenever(privatePaykitRepo.removePublishedEndpointsForCleanup(any())) - .thenReturn(Result.failure(AppError("No session"))) - advanceUntilIdle() - - sut.effects.test { - sut.signOut() - advanceUntilIdle() - assertEquals(ProfileEffect.SignedOut, awaitItem()) - } - - assertFalse(sut.uiState.value.isSigningOut) - verify(privatePaykitRepo, never()).removePublishedEndpointsForCleanup(any()) - verify(pubkyRepo, never()).signOut() - verify(privatePaykitRepo).closeAndClear() - } - - @Test - fun `failed local disconnect preserves private state`() = test { - val sut = createSut() - whenever(pubkyRepo.forgetUnrestoredIdentity()).thenReturn(Result.failure(AppError("Storage failed"))) - advanceUntilIdle() - - sut.signOut() - advanceUntilIdle() - - assertFalse(sut.uiState.value.isSigningOut) - verify(privatePaykitRepo, never()).closeAndClear() - verify(pubkyRepo, never()).signOut() - } - @Test fun `profile retry stays loading until session and profile are available`() = test { val sut = createSut() @@ -334,52 +285,6 @@ class ProfileViewModelTest : BaseUnitTest() { verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) } - @Test - fun `signOut marks profile recovery before signing out`() = test { - whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.signOut() - advanceUntilIdle() - - assertEquals(ProfileEffect.SignedOut, awaitItem()) - } - inOrder(privatePaykitRepo, pubkyRepo).apply { - verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) - verify(pubkyRepo).signOut() - verify(privatePaykitRepo).closeAndClear() - } - } - - @Test - fun `signOut stops when private cleanup fails`() = test { - val sut = createSut() - whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } - .thenReturn(Result.failure(ProfileTestAppError("cleanup failed"))) - advanceUntilIdle() - - sut.signOut() - advanceUntilIdle() - - assertFalse(sut.uiState.value.isSigningOut) - verify(pubkyRepo, never()).signOut() - verify(privatePaykitRepo, never()).closeAndClear() - } - - @Test - fun `signOut preserves local Paykit state when Pubky sign out fails`() = test { - val sut = createSut() - whenever(pubkyRepo.signOut()).thenReturn(Result.failure(ProfileTestAppError("sign out failed"))) - advanceUntilIdle() - - sut.signOut() - advanceUntilIdle() - - verify(privatePaykitRepo, never()).closeAndClear() - } - @Test fun `addTag saves the updated profile and closes the sheet`() = test { val sut = createSut(createProfile()) @@ -503,8 +408,198 @@ class ProfileViewModelTest : BaseUnitTest() { } } + @Test + fun `pending restart without credentials shows local recovery without authentication`() = test { + val pending = MutableStateFlow(null) + fixture.current = PubkyProfileOwner(null, 4) + val sut = createSut(publicKey = null, recoveryPending = pending) + val id = UUID.randomUUID().toString() + fixture.store.withAccess(id) { fixture.store.begin(id) } + advanceUntilIdle() + verify(pubkyRepo, never()).restoreSessionIfNeeded() + verify(pubkyRepo, never()).loadProfile() + sut.uiState.test { + awaitItem() + pending.value = true + advanceUntilIdle() + assertTrue(sut.uiState.value.recoveryPending) + assertFalse(sut.uiState.value.isLoading) + assertFalse(sut.uiState.value.canEdit) + sut.showSignOutConfirmation() + advanceUntilIdle() + assertTrue(sut.uiState.value.localDisconnect) + assertTrue(sut.uiState.value.showSignOutDialog) + sut.effects.test { + sut.signOut() + advanceUntilIdle() + assertIs(awaitItem()) + } + cancelAndIgnoreRemainingEvents() + } + verify(pubkyRepo, never()).signOutForProfileRemoval() + verify(fixture.privateRepo, never()).removePublishedEndpointsForCleanup(any(), eq(true)) + assertNull(fixture.store.pendingAttempt()) + } + + @Test + fun `sign out waits for confirmation and delayed effect is bound to ended owner`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + sut.signOut() + advanceUntilIdle() + verify(pubkyRepo, never()).signOutForProfileRemoval() + sut.showSignOutConfirmation() + advanceUntilIdle() + sut.effects.test { + sut.signOut() + sut.signOut() + advanceUntilIdle() + val effect = assertIs(awaitItem()) + assertTrue(sut.isRemovalCurrent(effect)) + fixture.current = PubkyProfileOwner(PubkySignIn("replacement", 3), 3) + assertFalse(sut.isRemovalCurrent(effect)) + expectNoEvents() + } + verify(pubkyRepo, times(1)).signOutForProfileRemoval() + } + + @Test + fun `uncertain sign out offers local confirmation without replaying revocation`() = test { + val sut = createSut(createProfile()) + whenever(pubkyRepo.signOutForProfileRemoval()).thenAnswer { throw ProfileTestAppError("lost response") } + advanceUntilIdle() + sut.showSignOutConfirmation() + advanceUntilIdle() + sut.effects.test { + sut.signOut() + advanceUntilIdle() + expectNoEvents() + } + assertNotNull(fixture.store.pendingAttempt()) + sut.showSignOutConfirmation() + advanceUntilIdle() + sut.effects.test { + sut.signOut() + advanceUntilIdle() + assertIs(awaitItem()) + } + verify(pubkyRepo, times(1)).signOutForProfileRemoval() + verify(fixture.privateRepo, times(1)).removePublishedEndpointsForCleanup(any(), eq(true)) + } + + @Test + fun `private completion failure is consumed and never reports signed out`() = test { + val sut = createSut(createProfile()) + whenever(fixture.privateRepo.finishProfileRemoval(fixture.pause)) + .thenReturn(Result.failure(ProfileTestAppError("storage unavailable"))) + advanceUntilIdle() + sut.showSignOutConfirmation() + advanceUntilIdle() + sut.effects.test { + sut.signOut() + advanceUntilIdle() + expectNoEvents() + } + assertNotNull(fixture.store.pendingAttempt()) + } + + @Test + fun `confirmation cannot sign out a replacement of the same identity`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + sut.showSignOutConfirmation() + advanceUntilIdle() + fixture.current = PubkyProfileOwner(PubkySignIn("pubkyalice", 3), 3) + sut.signOut() + advanceUntilIdle() + verify(pubkyRepo, never()).signOutForProfileRemoval() + verify(fixture.privateRepo, never()).finishProfileRemoval(any()) + } + + @Test + fun `unrelated control emission never recaptures a replacement owner`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + sut.uiState.test { + awaitItem() + fixture.current = PubkyProfileOwner(PubkySignIn("pubkyalice", 3), 3) + sut.showAddTagSheet() + advanceUntilIdle() + assertEquals(fixture.original, sut.uiState.value.owner) + sut.showSignOutConfirmation() + advanceUntilIdle() + assertFalse(sut.uiState.value.showSignOutDialog) + sut.signOut() + verify(pubkyRepo, never()).signOutForProfileRemoval() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `initial restoration binds once and later same-key sessions cannot replace its owner`() = test { + fixture.current = PubkyProfileOwner(null, fixture.original.storedRevision) + val profileFlow = MutableStateFlow(null) + val sut = createSut(profileFlow = profileFlow, publicKey = null) + advanceUntilIdle() + sut.uiState.test { + awaitItem() + fixture.current = fixture.original + profileFlow.value = createProfile() + advanceUntilIdle() + assertEquals(fixture.original, sut.uiState.value.owner) + fixture.current = PubkyProfileOwner(PubkySignIn("pubkyalice", 3), 3) + sut.showAddTagSheet() + advanceUntilIdle() + assertEquals(fixture.original, sut.uiState.value.owner) + sut.showSignOutConfirmation() + advanceUntilIdle() + assertFalse(sut.uiState.value.showSignOutDialog) + sut.signOut() + verify(pubkyRepo, never()).signOutForProfileRemoval() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `cancelled local completion retains its owner through a later admission failure`() = test { + val sut = createSut(createProfile()) + org.mockito.kotlin.doAnswer { + fixture.current = fixture.ended + throw CancellationException("Cancelled after acknowledged credential removal") + }.whenever(pubkyRepo).finishProfileRemovalLocally() + advanceUntilIdle() + sut.showSignOutConfirmation() + advanceUntilIdle() + sut.effects.test { + sut.signOut() + advanceUntilIdle() + expectNoEvents() + assertNotNull(fixture.store.pendingAttempt()) + verify(fixture.privateRepo, never()).finishProfileRemoval(any()) + sut.showSignOutConfirmation() + advanceUntilIdle() + fixture.admissionFailure = IllegalStateException("Temporarily busy") + sut.signOut() + advanceUntilIdle() + expectNoEvents() + assertNotNull(fixture.store.pendingAttempt()) + fixture.admissionFailure = null + org.mockito.kotlin.doAnswer { fixture.current = fixture.ended }.whenever( + pubkyRepo + ).finishProfileRemovalLocally() + sut.showSignOutConfirmation() + advanceUntilIdle() + sut.signOut() + advanceUntilIdle() + assertIs(awaitItem()) + assertNull(fixture.store.pendingAttempt()) + } + verify(pubkyRepo, times(1)).signOutForProfileRemoval() + } + private fun createSut( profile: PubkyProfile? = null, + recoveryPending: MutableStateFlow = MutableStateFlow(false), profileFlow: MutableStateFlow = MutableStateFlow(profile), readOnlyProfileFlow: MutableStateFlow = MutableStateFlow(null), publicKey: String? = "pubkyalice", @@ -517,7 +612,8 @@ class ProfileViewModelTest : BaseUnitTest() { onRestore: suspend () -> Boolean = { false }, ): ProfileViewModel { whenever(context.getString(any())).thenReturn("") - whenever { pubkyRepo.forgetUnrestoredIdentity() }.thenReturn(Result.success(false)) + fixture.setup() + whenever(pubkyRepo.profileRecoveryPending).thenReturn(recoveryPending) whenever(pubkyRepo.profile).thenReturn(profileFlow) whenever(pubkyRepo.readOnlyProfile).thenReturn(readOnlyProfileFlow) whenever(pubkyRepo.publicKey).thenReturn(publicKeyFlow) @@ -526,16 +622,12 @@ class ProfileViewModelTest : BaseUnitTest() { whenever(pubkyRepo.cachedProfile).thenReturn(MutableStateFlow(cachedProfile)) whenever { pubkyRepo.restoreSessionIfNeeded() }.doSuspendableAnswer { onRestore() } whenever { pubkyRepo.loadProfile() }.thenAnswer { onLoadProfile() } - whenever { pubkyRepo.signOut() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.saveProfile(any(), any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) return ProfileViewModel( context = context, pubkyRepo = pubkyRepo, - privatePaykitRepo = privatePaykitRepo, + removalCoordinator = fixture.coordinator, ) } diff --git a/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt b/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt new file mode 100644 index 0000000000..a573bb22f7 --- /dev/null +++ b/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt @@ -0,0 +1,416 @@ +package to.bitkit.usecases + +import androidx.datastore.core.DataStore +import com.synonym.paykit.ContactRecord +import com.synonym.paykit.PublicationStatus +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.ProfileRemovalState +import to.bitkit.data.ProfileRemovalStore +import to.bitkit.ext.runSuspendCatching +import to.bitkit.repositories.ContactPaymentSettingsRepo +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileDeletion +import to.bitkit.repositories.PubkyProfileOwner +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn +import to.bitkit.services.PaykitProfileDeletionSnapshot +import to.bitkit.test.BaseUnitTest +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ProfileRemovalCoordinatorTest : BaseUnitTest() { + private val pubky = mock() + private val privateRepo = mock() + private val sharing = mock() + private var store = ProfileRemovalStore.inMemoryForTesting() + private val original = PubkyProfileOwner(PubkySignIn("identity", 1), 1) + private val ended = PubkyProfileOwner(null, 2) + private var current = original + private var rejectAdmission = false + private val pause = PrivatePaykitRepo.ProfileDeletionPause() + private val events = mutableListOf() + private val deletion = + PubkyProfileDeletion(original, 1, PaykitProfileDeletionSnapshot("identity", "revision", emptyList())) + private val sut get() = ProfileRemovalCoordinator(pubky, privateRepo, sharing, store) + + @Before + fun setup() = test { + whenever(pubky.profileOwner()).thenAnswer { current } + whenever(pubky.isProfileOwnerCurrent(any())).thenAnswer { it.getArgument(0) === current } + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(deletion)) + whenever { sharing.withProfileRemoval(any()) }.doSuspendableAnswer { + it.getArgument PubkyProfileOwner>(0).invoke() + } + whenever { pubky.performProfileRemoval(any(), any(), any()) }.doSuspendableAnswer { + val owner = it.getArgument(0) + val id = it.getArgument(1) + val operation = it.getArgument Unit>(2) + runSuspendCatching { + check(!rejectAdmission) { "busy before operation admission" } + check(owner === current) + store.withAccess(id) { operation() } + current + } + } + whenever { privateRepo.withProfileDeletionPaused(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0).invoke(pause) + } + whenever { pubky.validateProfileDeletion(deletion) }.doSuspendableAnswer { + events += "validate" + Unit + } + whenever { privateRepo.removePublishedEndpointsForCleanup(any(), eq(true)) }.doSuspendableAnswer { + assertNotNull(store.pendingAttempt()) + events += "private endpoints" + Result.success(Unit) + } + whenever { pubky.removeBitkitPaymentEndpoints() }.thenAnswer { + events += "public endpoints" + Result.success(Unit) + } + whenever { pubky.deleteProfile(deletion) }.thenAnswer { + events += "profile" + Result.success(Unit) + } + whenever { pubky.signOutForProfileRemoval() }.thenAnswer { + events += "revoke" + Unit + } + whenever { pubky.finishProfileRemovalLocally() }.doSuspendableAnswer { + assertNotNull(store.pendingAttempt()) + events += "local credentials and metadata" + current = ended + } + whenever { privateRepo.finishProfileRemoval(pause) }.doSuspendableAnswer { + assertNotNull(store.pendingAttempt()) + events += "private local" + Result.success(Unit) + } + } + + @Test + fun `confirmed removal acknowledges credentials before clearing private state and clears marker last`() = test { + val confirmation = sut.prepareDeletion(original).getOrThrow() + assertNull(store.pendingAttempt()) + assertSame(ended, sut.remove(confirmation).getOrThrow()) + assertEquals( + listOf( + "validate", + "private endpoints", + "public endpoints", + "profile", + "revoke", + "local credentials and metadata", + "private local" + ), + events + ) + assertNull(store.pendingAttempt()) + assertFalse(sut.isCurrent(original)) + assertTrue(sut.isCurrent(ended)) + } + + @Test + fun `one confirmation cannot repeat an uncertain profile mutation`() = test { + whenever(pubky.deleteProfile(deletion)).thenReturn(Result.failure(IllegalStateException("lost response"))) + val confirmation = sut.prepareDeletion(original).getOrThrow() + assertIs(sut.remove(confirmation).exceptionOrNull()) + assertTrue(sut.remove(confirmation).isFailure) + verify(pubky, times(1)).deleteProfile(deletion) + verify(pubky, never()).signOutForProfileRemoval() + verify(privateRepo, never()).finishProfileRemoval(any()) + assertEquals(confirmation.attemptId, store.pendingAttempt()) + } + + @Test + fun `review after uncertain deletion reads original owner and requires another confirmation`() = test { + whenever(pubky.deleteProfile(deletion)).thenReturn(Result.failure(IllegalStateException("lost response"))) + val first = sut.prepareDeletion(original).getOrThrow() + val failure = assertIs(sut.remove(first).exceptionOrNull()) + val second = sut.reviewAgain(failure).getOrThrow() + assertEquals(first.attemptId, second.attemptId) + assertFalse(second.used.get()) + verify(pubky, times(2)).prepareProfileDeletion(original) + verify(pubky, times(1)).deleteProfile(deletion) + current = PubkyProfileOwner(PubkySignIn("replacement", 3), 3) + assertTrue(sut.reviewAgain(failure).isFailure) + assertTrue(sut.prepareLocalDisconnect(failure).isFailure) + } + + @Test + fun `review rejects a later profile revision instead of replacing original intent`() = test { + val failure = failRemoval(deletion) + whenever(pubky.prepareProfileDeletion(original)).thenReturn( + Result.success(deletion.withSnapshot(deletion.snapshot.copy(profileRevision = "later-edit"))) + ) + assertTrue(sut.reviewAgain(failure).isFailure) + verify(pubky, times(1)).deleteProfile(deletion) + } + + @Test + fun `review rejects added or edited contacts before another confirmation`() = test { + val alice = contact("alice") + val first = deletion.withSnapshot(deletion.snapshot.copy(contacts = listOf(alice))) + val failure = failRemoval(first) + for (records in listOf(listOf(alice, contact("bob")), listOf(alice.copy(label = "Later edit")))) { + whenever(pubky.prepareProfileDeletion(original)).thenReturn( + Result.success(first.withSnapshot(first.snapshot.copy(contacts = records))) + ) + assertTrue(sut.reviewAgain(failure).isFailure) + } + verify(pubky, times(1)).deleteProfile(first) + } + + @Test + fun `review accepts only unchanged residuals and cannot restore a removed contact target`() = test { + val alice = contact("alice") + val bob = contact("bob") + val first = deletion.withSnapshot(deletion.snapshot.copy(contacts = listOf(alice, bob))) + val failure = failRemoval(first) + val residual = first.withSnapshot(first.snapshot.copy(profileRevision = null, contacts = listOf(bob))) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(residual)) + val second = sut.reviewAgain(failure).getOrThrow() + assertEquals(residual, second.deletion) + assertEquals(failure.confirmation.attemptId, second.attemptId) + verify(pubky, times(1)).deleteProfile(first) + whenever(pubky.deleteProfile(residual)).thenReturn(Result.failure(IllegalStateException("lost response"))) + val secondFailure = assertIs(sut.remove(second).exceptionOrNull()) + whenever(pubky.prepareProfileDeletion(original)).thenReturn( + Result.success(residual.withSnapshot(residual.snapshot.copy(contacts = listOf(alice, bob)))) + ) + assertTrue(sut.reviewAgain(secondFailure).isFailure) + verify(pubky, times(1)).deleteProfile(residual) + } + + @Test + fun `review rejects a profile recreated after an earlier review observed absence`() = test { + val failure = failRemoval(deletion) + val absent = deletion.withSnapshot(deletion.snapshot.copy(profileRevision = null)) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(absent)) + val second = sut.reviewAgain(failure).getOrThrow() + whenever(pubky.deleteProfile(absent)).thenReturn(Result.failure(IllegalStateException("unknown"))) + val secondFailure = assertIs(sut.remove(second).exceptionOrNull()) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(deletion)) + assertTrue(sut.reviewAgain(secondFailure).isFailure) + } + + @Test + fun `old failure cannot expand residuals after a confirmation admission failure`() = test { + val first = deletion.withSnapshot(deletion.snapshot.copy(contacts = listOf(contact("alice"), contact("bob")))) + val failure = failRemoval(first) + val remaining = first.withSnapshot(first.snapshot.copy(contacts = listOf(contact("bob")))) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(remaining)) + val second = sut.reviewAgain(failure).getOrThrow() + rejectAdmission = true + assertTrue(sut.remove(second).isFailure) + assertNull(sut.failureFor(second)) + rejectAdmission = false + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(first)) + assertTrue(sut.reviewAgain(failure).isFailure) + verify(pubky, times(1)).deleteProfile(first) + verify(pubky, never()).deleteProfile(remaining) + } + + @Test + fun `cancelled residual review cannot reintroduce an absent profile through an older failure`() = test { + val failure = failRemoval(deletion) + val remaining = deletion.withSnapshot(deletion.snapshot.copy(profileRevision = null)) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(remaining)) + sut.reviewAgain(failure).getOrThrow() + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(deletion)) + assertTrue(sut.reviewAgain(failure).isFailure) + verify(pubky, times(1)).deleteProfile(deletion) + } + + @Test + fun `new residual review invalidates an outstanding wider confirmation before cleanup`() = test { + val first = deletion.withSnapshot(deletion.snapshot.copy(contacts = listOf(contact("alice"), contact("bob")))) + val failure = failRemoval(first) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(first)) + val wider = sut.reviewAgain(failure).getOrThrow() + val remaining = first.withSnapshot(first.snapshot.copy(contacts = listOf(contact("bob")))) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(remaining)) + sut.reviewAgain(failure).getOrThrow() + val eventsBefore = events.toList() + assertTrue(sut.remove(wider).isFailure) + assertEquals(eventsBefore, events) + verify(pubky, times(1)).deleteProfile(first) + } + + @Test + fun `review read failure retains original obligation without a mutation`() = test { + val failure = failRemoval(deletion) + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.failure(IllegalStateException("offline"))) + assertTrue(sut.reviewAgain(failure).isFailure) + assertEquals(failure.confirmation.attemptId, store.pendingAttempt()) + verify(pubky, times(1)).deleteProfile(deletion) + } + + private fun PubkyProfileDeletion.withSnapshot(snapshot: PaykitProfileDeletionSnapshot) = + PubkyProfileDeletion(owner, profileWriteRevision, snapshot) + + private suspend fun failRemoval(snapshot: PubkyProfileDeletion): ProfileRemovalFailure { + whenever(pubky.prepareProfileDeletion(original)).thenReturn(Result.success(snapshot)) + whenever(pubky.deleteProfile(snapshot)).thenReturn(Result.failure(IllegalStateException("lost response"))) + return assertIs(sut.remove(sut.prepareDeletion(original).getOrThrow()).exceptionOrNull()) + } + + private fun contact(key: String) = ContactRecord( + publicKey = key, + label = key, + profile = null, + profileFetchedAt = null, + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-01T00:00:00Z", + publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, + publicContactPublishedAt = null, + publicContactRemovedAt = null, + publicContactLastError = null, + ) + + @Test + fun `later identity invalidates an unused confirmation before cleanup`() = test { + val confirmation = sut.prepareDeletion(original).getOrThrow() + current = ended + assertTrue(sut.remove(confirmation).isFailure) + assertNull(store.pendingAttempt()) + assertTrue(events.isEmpty()) + } + + @Test + fun `changed snapshot fails before durable marker and destructive work`() = test { + whenever { pubky.validateProfileDeletion(deletion) }.thenAnswer { error("profile revision changed") } + val confirmation = sut.prepareDeletion(original).getOrThrow() + assertTrue(sut.remove(confirmation).isFailure) + assertNull(store.pendingAttempt()) + verify(privateRepo, never()).removePublishedEndpointsForCleanup(any(), any()) + } + + @Test + fun `explicit local disconnect remains available after precondition failure without a marker`() = test { + whenever { pubky.validateProfileDeletion(deletion) }.thenAnswer { error("profile changed") } + val failure = assertIs( + sut.remove(sut.prepareDeletion(original).getOrThrow()).exceptionOrNull() + ) + assertNull(store.pendingAttempt()) + assertTrue(sut.remove(sut.prepareLocalDisconnect(failure).getOrThrow()).isSuccess) + assertNull(store.pendingAttempt()) + verify(pubky, never()).deleteProfile(any()) + verify(pubky, never()).signOutForProfileRemoval() + } + + @Test + fun `public cleanup failure retains marker and never revokes or clears private state`() = test { + whenever(pubky.removeBitkitPaymentEndpoints()).thenReturn(Result.failure(IllegalStateException("unknown"))) + val confirmation = sut.prepareDeletion(original).getOrThrow() + assertTrue(sut.remove(confirmation).isFailure) + assertEquals(confirmation.attemptId, store.pendingAttempt()) + verify(pubky, never()).deleteProfile(any()) + verify(pubky, never()).signOutForProfileRemoval() + verify(privateRepo, never()).finishProfileRemoval(any()) + } + + @Test + fun `failed local completion retains ended owner for explicit local disconnect`() = test { + doReturn(Result.failure(IllegalStateException("disk"))).whenever(privateRepo).finishProfileRemoval(pause) + val confirmation = sut.prepareDeletion(original).getOrThrow() + val failure = assertIs(sut.remove(confirmation).exceptionOrNull()) + assertTrue(failure.localCompletionStarted) + assertSame(ended, failure.recoveryOwner) + assertTrue(sut.reviewAgain(failure).isFailure) + val recovery = sut.prepareLocalDisconnect(failure).getOrThrow() + doReturn(Result.success(Unit)).whenever(privateRepo).finishProfileRemoval(pause) + assertTrue(sut.remove(recovery).isSuccess) + assertNull(store.pendingAttempt()) + verify(pubky, times(1)).deleteProfile(deletion) + verify(pubky, times(1)).signOutForProfileRemoval() + } + + @Test + fun `restart pending disconnect is local only and rejects stale UUID confirmation`() = test { + current = PubkyProfileOwner(null, 0) + val id = UUID.randomUUID().toString() + store.withAccess(id) { store.begin(id) } + val confirmation = sut.prepareSignOut(current).getOrThrow() + assertTrue(confirmation.localOnly) + assertTrue(sut.remove(confirmation).isSuccess) + verify(pubky, never()).signOutForProfileRemoval() + verify(pubky, never()).removeBitkitPaymentEndpoints() + verify(privateRepo, never()).removePublishedEndpointsForCleanup(any(), any()) + val unusedOldConfirmation = sut.prepareSignOut(current).getOrThrow() + val newerId = UUID.randomUUID().toString() + store.withAccess(newerId) { store.begin(newerId) } + assertTrue(sut.remove(confirmation).isFailure) + assertFalse(unusedOldConfirmation.used.get()) + assertEquals( + "Another profile removal needs local recovery", + sut.remove(unusedOldConfirmation).exceptionOrNull()?.message, + ) + assertEquals(newerId, store.pendingAttempt()) + } + + @Test + fun `failed final marker persistence retains local recovery without repeating remote work`() = test { + val data = MutableStateFlow(ProfileRemovalState()) + var rejectCompletion = true + store = ProfileRemovalStore(object : DataStore { + override val data = data + override suspend fun updateData( + transform: suspend (ProfileRemovalState) -> ProfileRemovalState + ): ProfileRemovalState { + val next = transform(data.value) + if (rejectCompletion && next.attemptId == null) error("marker write unavailable") + data.value = next + return next + } + }) + val confirmation = sut.prepareDeletion(original).getOrThrow() + val failure = assertIs(sut.remove(confirmation).exceptionOrNull()) + assertSame(ended, failure.recoveryOwner) + assertEquals(confirmation.attemptId, store.pendingAttempt()) + assertTrue(sut.reviewAgain(failure).isFailure) + rejectCompletion = false + assertTrue(sut.remove(sut.prepareLocalDisconnect(failure).getOrThrow()).isSuccess) + assertNull(store.pendingAttempt()) + verify(pubky, times(1)).deleteProfile(deletion) + verify(pubky, times(1)).signOutForProfileRemoval() + } + + @Test + fun `cancellation during destructive work leaves obligation and does not clear watermarks`() = test { + val entered = CompletableDeferred() + val wait = CompletableDeferred() + whenever { pubky.deleteProfile(deletion) }.doSuspendableAnswer { + entered.complete(Unit) + wait.await() + Result.success(Unit) + } + val confirmation = sut.prepareDeletion(original).getOrThrow() + val job = async { sut.remove(confirmation) } + entered.await() + job.cancelAndJoin() + assertTrue(job.isCancelled) + assertEquals(confirmation.attemptId, store.pendingAttempt()) + verify(privateRepo, never()).finishProfileRemoval(any()) + } +} diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 0c14b2144b..5a67ada663 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -4,10 +4,16 @@ import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -19,13 +25,20 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppDb import to.bitkit.data.CacheStore +import to.bitkit.data.ProfileRemovalStore +import to.bitkit.data.PubkyImageCacheEpoch +import to.bitkit.data.PubkyStore +import to.bitkit.data.PubkyStoreData +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState @@ -38,14 +51,21 @@ import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.PaykitSdkService +import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest +import java.util.UUID import javax.inject.Provider +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class WipeWalletUseCaseTest : BaseUnitTest() { private val backupRepo = mock() @@ -63,11 +83,20 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val hwWalletRepo = mock() private val lightningRepo = mock() private val pubkyRepo = mock() + private val profileRemovalStore = ProfileRemovalStore.inMemoryForTesting() + private val contactPaymentSettingsRepo = mock() + private val profilePause = PrivatePaykitRepo.ProfileDeletionPause() private val privatePaykitRepo = mock() private val privatePaykitAddressReservationRepo = mock() private val firebaseMessaging = mock() private val migrationService = mock() - private val paykitSdkService = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { mock() } + private val paykitSdkService = PaykitSdkService( + mock(), + keychain, + mock(), + settingsStore = mock(), + profileRemovalStore = profileRemovalStore, + ) { mock() } private val privatePaykitRepoProvider = Provider { privatePaykitRepo } private lateinit var sut: WipeWalletUseCase @@ -77,6 +106,17 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Before fun setUp() { + whenever { contactPaymentSettingsRepo.withWalletWipe(any(), any()) }.doSuspendableAnswer { + val admit = it.getArgument Unit) -> Unit>(0) + admit(it.getArgument(1)) + } + whenever { privatePaykitRepo.withProfileDeletionPaused(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0).invoke(profilePause) + } + whenever { privatePaykitRepo.finishProfileRemoval(any()) }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.withWalletWipeAdmission(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0).invoke() + } whenever { lightningRepo.stop() }.thenReturn(Result.success(Unit)) whenever { usdtRepo.wipe(any()) }.doSuspendableAnswer { it.getArgument Unit>(0).invoke() @@ -91,28 +131,211 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onWipeCalled = false onSetWalletExistsStateCalled = false - sut = WipeWalletUseCase( - backupRepo = backupRepo, + sut = createSut() + } + + private fun createSut( + pubkyRepo: PubkyRepo = this.pubkyRepo, + sharingRepo: ContactPaymentSettingsRepo = contactPaymentSettingsRepo, + ) = WipeWalletUseCase( + backupRepo = backupRepo, + keychain = keychain, + coreService = coreService, + usdtRepo = usdtRepo, + paykitUsdtPaymentRepo = paykitUsdtPaymentRepo, + db = db, + settingsStore = settingsStore, + cacheStore = cacheStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, + widgetsStore = widgetsStore, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + hwWalletRepo = hwWalletRepo, + lightningRepo = lightningRepo, + pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepoProvider, + privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, + firebaseMessaging = firebaseMessaging, + migrationService = migrationService, + paykitSdkService = paykitSdkService, + profileRemovalStore = profileRemovalStore, + contactPaymentSettingsRepo = Provider { sharingRepo }, + ) + + private fun realPubkyRepo(service: PubkyService, shared: SharedPubkyClient = mock()): PubkyRepo { + whenever { service.requireWalletWipe() }.doSuspendableAnswer { paykitSdkService.requireWalletWipe() } + whenever { service.observedContactRecords(any()) }.doSuspendableAnswer { service.contactRecords() } + val store = mock() + whenever(store.data).thenReturn(MutableStateFlow(PubkyStoreData())) + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(MutableStateFlow(false)) + return PubkyRepo( + ioDispatcher = testDispatcher, + pubkyService = service, keychain = keychain, - coreService = coreService, - usdtRepo = usdtRepo, - paykitUsdtPaymentRepo = paykitUsdtPaymentRepo, - db = db, + sharedPubkyClient = shared, + imageLoader = mock(), + imageCacheEpoch = PubkyImageCacheEpoch(), + pubkyStore = store, settingsStore = settingsStore, - cacheStore = cacheStore, - watchOnlyAccountRepo = watchOnlyAccountRepo, - widgetsStore = widgetsStore, - blocktankRepo = blocktankRepo, - activityRepo = activityRepo, - hwWalletRepo = hwWalletRepo, - lightningRepo = lightningRepo, - pubkyRepo = pubkyRepo, - privatePaykitRepo = privatePaykitRepoProvider, - privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, - firebaseMessaging = firebaseMessaging, - migrationService = migrationService, - paykitSdkService = paykitSdkService, + httpClient = mock(), + clock = Clock.System, + profileRemovalStore = profileRemovalStore, + ) + } + + @Test + fun `wipe drains real repository removal and noncancellable local cleanup before SDK reset`() = test { + val service = mock() + whenever { service.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + paykitSdkService.withProfileRemoval(it.getArgument(0), it.getArgument(1)) + } + val repo = realPubkyRepo(service) + val sharing = ContactPaymentSettingsRepo(settingsStore, mock(), privatePaykitRepo, repo, testDispatcher) + advanceUntilIdle() + paykitSdkService.initialize() + val entered = CompletableDeferred() + val cleanupStarted = CompletableDeferred() + val releaseCleanup = CompletableDeferred() + val removed = launch { + sharing.withProfileRemoval { + repo.performProfileRemoval(repo.profileOwner()) { + entered.complete(Unit) + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + cleanupStarted.complete(Unit) + releaseCleanup.await() + } + } + } + } + } + withTimeout(5_000) { entered.await() } + val wipe = async { + withTimeout(5_000) { createSut(repo, sharing).invoke(resetWalletState = {}, onSuccess = {}) } + } + try { + cleanupStarted.await() + runCurrent() + assertFalse(wipe.isCompleted) + verify(backupRepo, never()).reset() + verify(keychain, never()).wipe() + } finally { + releaseCleanup.complete(Unit) + } + removed.join() + assertTrue(wipe.await().isSuccess) + verify(keychain).wipe() + } + + @Test + fun `wipe drains real Ring adoption local tail before entering SDK wipe barrier`() = test { + val service = mock() + val shared = mock() + val rawKey = "5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + whenever { shared.ringCredential(rawKey) }.thenReturn(Result.success("ring_secret")) + whenever { service.publicKeyFromSecret("ring_secret") }.thenReturn(rawKey) + val entered = CompletableDeferred() + val releaseContacts = CompletableDeferred() + whenever { service.contactRecords() }.doSuspendableAnswer { + entered.complete(Unit) + releaseContacts.await() + emptyList() + } + val repo = realPubkyRepo(service, shared) + advanceUntilIdle() + paykitSdkService.initialize() + val adoption = async { repo.adoptRingIdentity(rawKey) } + withTimeout(5_000) { entered.await() } + val wipe = async { + withTimeout(5_000) { createSut(repo).invoke(resetWalletState = {}, onSuccess = {}) } + } + try { + runCurrent() + assertFalse(wipe.isCompleted) + verify(backupRepo, never()).reset() + verify(keychain, never()).wipe() + } finally { + releaseContacts.complete(Unit) + } + adoption.await().getOrThrow() + wipe.await().getOrThrow() + verify(keychain).wipe() + } + + @Test + fun `pending restart wipe skips remote cleanup and only clears marker after local acknowledgement`() = test { + val id = UUID.randomUUID().toString() + profileRemovalStore.withAccess(id) { profileRemovalStore.begin(id) } + whenever { privatePaykitRepo.finishProfileRemoval(any()) }.doSuspendableAnswer { + assertEquals(id, profileRemovalStore.pendingAttempt()) + verify(keychain).wipe() + Result.success(Unit) + } + assertTrue(sut.invoke(resetWalletState = {}, onSuccess = {}).isSuccess) + assertNull(profileRemovalStore.pendingAttempt()) + verify(privatePaykitRepo, never()).removePublishedEndpointsForCleanup(any()) + verify(pubkyRepo, never()).removeBitkitPaymentEndpoints() + verify(privatePaykitRepo, never()).closeAndClear() + } + + @Test + fun `private persistence failure retains marker and suppresses completion callbacks`() = test { + whenever { + privatePaykitRepo.finishProfileRemoval( + any() + ) + }.thenReturn(Result.failure(IllegalStateException("disk"))) + assertTrue( + sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true } + ).isFailure ) + verify(keychain).wipe() + assertNotNull(profileRemovalStore.pendingAttempt()) + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + } + + @Test + fun `profile metadata failure retains recovery after credentials were wiped`() = test { + val failure = IllegalStateException("profile persistence unavailable") + whenever { pubkyRepo.finishProfileWipeLocally() }.thenThrow(failure) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertEquals(failure.message, result.exceptionOrNull()?.message) + verify(keychain).wipe() + verify(privatePaykitRepo).finishProfileRemoval(profilePause) + verify(privatePaykitAddressReservationRepo, never()).clear() + assertNotNull(profileRemovalStore.pendingAttempt()) + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + } + + @Test + fun `settings failure after credential teardown retains recovery without success`() = test { + val failure = IllegalStateException("settings persistence unavailable") + whenever { settingsStore.reset() }.thenThrow(failure) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertEquals(failure.message, result.exceptionOrNull()?.message) + verify(keychain).wipe() + verify(pubkyRepo).finishProfileWipeLocally() + verify(privatePaykitAddressReservationRepo).clear() + assertNotNull(profileRemovalStore.pendingAttempt()) + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) } @Test @@ -149,15 +372,15 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(lightningRepo).stop() inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() - inOrder.verify(privatePaykitRepo).closeAndClear() inOrder.verify(lightningRepo).wipeStorage(0) inOrder.verify(migrationService).setNeedsPostMigrationSync(false) inOrder.verify(migrationService).cleanupAfterMigration() - inOrder.verify(privatePaykitAddressReservationRepo).clear() - inOrder.verify(pubkyRepo).wipeLocalState() inOrder.verify(usdtRepo).wipe(any()) inOrder.verify(keychain).wipe() inOrder.verify(paykitUsdtPaymentRepo).resetForWipe() + inOrder.verify(privatePaykitRepo).finishProfileRemoval(profilePause) + inOrder.verify(pubkyRepo).finishProfileWipeLocally() + inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(coreService).wipeData() inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() @@ -217,7 +440,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { } @Test - fun `invoke should continue when address reservation clear fails`() = runTest { + fun `invoke retains pending recovery when address reservation clear fails`() = runTest { whenever { privatePaykitAddressReservationRepo.clear() }.thenThrow(RuntimeException("clear failed")) val result = sut.invoke( @@ -225,25 +448,30 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isSuccess) + assertTrue(result.isFailure) verify(keychain).wipe() - assertTrue(onSetWalletExistsStateCalled) + assertFalse(onSetWalletExistsStateCalled) + assertFalse(onWipeCalled) + assertNotNull(profileRemovalStore.pendingAttempt()) } @Test - fun `invoke should fail after wiping the rest when keychain wipe fails`() = runTest { - whenever(keychain.wipe()).thenThrow(RuntimeException("keystore write failed")) + fun `invoke retains private safety state and recovery when keychain wipe fails`() = runTest { + val error = RuntimeException("keystore write failed") + whenever(keychain.wipe()).thenThrow(error) val result = sut.invoke( resetWalletState = { onWipeCalled = true }, onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertIs(result.exceptionOrNull()) + assertEquals(error, assertIs(result.exceptionOrNull()).cause) verify(lightningRepo).wipeStorage(0) - verify(db).clearAllTables() - verify(settingsStore).reset() - assertTrue(onWipeCalled) + verify(db, never()).clearAllTables() + verify(settingsStore, never()).reset() + assertFalse(onWipeCalled) + verify(privatePaykitRepo, never()).finishProfileRemoval(any()) + assertNotNull(profileRemovalStore.pendingAttempt()) assertFalse(onSetWalletExistsStateCalled) } @@ -303,7 +531,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) assertTrue(result.isSuccess) - verify(pubkyRepo).wipeLocalState() + verify(pubkyRepo).finishProfileWipeLocally() verify(keychain).wipe() verify(backupRepo).setWiping(false) } @@ -320,8 +548,8 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) assertTrue(result.isSuccess) - verify(privatePaykitRepo).closeAndClear() - verify(pubkyRepo).wipeLocalState() + verify(privatePaykitRepo).finishProfileRemoval(profilePause) + verify(pubkyRepo).finishProfileWipeLocally() verify(keychain).wipe() verify(backupRepo).setWiping(false) } @@ -337,7 +565,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) assertTrue(result.isFailure) - verify(privatePaykitRepo).closeAndClear() + verify(privatePaykitRepo, never()).finishProfileRemoval(any()) verify(keychain, never()).wipe() verify(db, never()).clearAllTables() assertFalse(onWipeCalled) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ea979a6e41..265c3ab25a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -249,7 +249,7 @@ import com.synonym.bitkitcore.Activity as BitkitActivity @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) +@Config(application = android.app.Application::class, sdk = [34]) class AppViewModelSendFlowTest : BaseUnitTest() { private lateinit var sut: AppViewModel @@ -731,6 +731,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { pubkyRepo.awaitIdentityReady() }.thenAnswer { if (pubkyPublicKey.value != null) PubkyIdentityReadiness.Ready else PubkyIdentityReadiness.Missing } + whenever { pubkyRepo.requiresProfileRecovery() }.thenReturn(false) + whenever(pubkyRepo.profileRecoveryPending).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) whenever { pubkyRepo.refreshContacts() }.thenReturn(true) whenever { pubkyRepo.importShopContacts() }.thenReturn(Result.success(Unit)) @@ -4957,6 +4959,74 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `pending removal blocks scanner and signup authorization before retained key fallback`() = test { + enablePaykitUi() + advanceUntilIdle() + whenever(pubkyRepo.requiresProfileRecovery()).thenReturn(true) + whenever(pubkyRepo.localIdentityPublicKey()).thenReturn(testPublicKey) + whenever(context.getString(R.string.profile__cleanup_pending)).thenReturn("Complete local cleanup") + listOf("pubkyauth://signin_grant", grantSignupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl -> + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + assertNull(sut.currentSheet.value) + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult(authUrl) + advanceUntilIdle() + assertFalse(sut.currentSheet.value is Sheet.PubkyAuth) + sut.hideSheet() + advanceUntilIdle() + } + verify(pubkyRepo, never()).localIdentityPublicKey() + verify(pubkyRepo, never()).hasSecretKey() + verify(context, atLeastOnce()).getString(R.string.profile__cleanup_pending) + } + + @Test + fun `pending removal ends offline auth waiting and releases the scan queue`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setIsAuthenticated(true) + val pending = MutableStateFlow(false) + whenever(pubkyRepo.profileRecoveryPending).thenReturn(pending) + whenever(pubkyRepo.requiresProfileRecovery()).thenAnswer { pending.value == true } + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.awaitIdentityReady()).thenAnswer { + if (pending.value == true) PubkyIdentityReadiness.RecoveryRequired else PubkyIdentityReadiness.Unavailable + } + connectivityState.value = ConnectivityState.DISCONNECTED + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceTimeBy(10_000) + runCurrent() + pending.value = true + runCurrent() + assertNull(sut.currentSheet.value) + verify(toastManager).dismiss(check { assertTrue(it.isLoading) }) + verify(pubkyRepo, never()).localIdentityPublicKey() + val bolt11 = "lnbcrt1postpendingcleanupscan" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + connectivityState.value = ConnectivityState.CONNECTED + sut.onScanResult(bolt11) + advanceUntilIdle() + assertTrue(sut.currentSheet.value is Sheet.Send) + } + + @Test + fun `auth sheet cannot install after pending starts during its transition`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.showScannerSheet() + advanceUntilIdle() + sut.showSheet(Sheet.PubkyAuth("pubkyauth://signin_grant")) + runCurrent() + whenever(pubkyRepo.requiresProfileRecovery()).thenReturn(true) + advanceUntilIdle() + assertNull(sut.currentSheet.value) + verify(context).getString(R.string.profile__cleanup_pending) + } + @Test fun `cold pubky auth deeplink waits for settings identity and wallet unlock`() = test { val initialized = CompletableDeferred() diff --git a/changelog.d/next/1687.fixed.md b/changelog.d/next/1687.fixed.md new file mode 100644 index 0000000000..c0a4d618a7 --- /dev/null +++ b/changelog.d/next/1687.fixed.md @@ -0,0 +1 @@ +Keep interrupted profile removal recoverable and prevent retries or delayed confirmations from changing a replacement identity. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 3a86acdfe4..244f505f55 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -4,11 +4,11 @@ `unlink-ring-pubky.xml` verifies that a pubky adopted from Pubky Ring shows "Unlink pubky" instead of "Delete Profile" in Edit Profile, returns to profile onboarding after unlinking, and stays available in Pubky Ring. It needs Pubky Ring (`to.pubky.ring`) on the same emulator or device, signed with the same key as the Bitkit build (`app/debug.keystore` for a dev build), which is not a listed capability. Use a disposable Ring identity; a profile created in Bitkit keeps "Delete Profile", covered by `delete-profile.xml`. -During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. After a failed deletion, normal automatic work must resume for the retained identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee. +During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. Once destructive cleanup has begun, its durable pending state keeps authenticated work paused even after a failure or restart. Only an explicit local Disconnect may finish the interrupted local cleanup; it does not claim that the remote profile was deleted. A failure before that durable checkpoint may resume normal work for the unchanged identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee. Contact-link retention after deletion is platform-specific. Android cancels a scan awaiting a signed-in contacts refresh when deletion signs out; it does not retry that scan as the iOS retained-link flow does. This change does not alter that cancellation behavior. -If public invoice replacement is deferred during deletion, make deletion fail and the first resumed publication fail transiently. Verify that public publication resumes without waiting for contact refresh to complete, remains pending after the publication error, and is retried by normal foreground maintenance without another foreground event or payment. After sign-out or identity replacement, an older refresh must not publish for the new identity. Controlled payment events and publication failures require gates beyond the journey runner capabilities. +If public invoice replacement is deferred before destructive deletion begins, make preparation fail before the durable cleanup checkpoint and the first resumed publication fail transiently. Verify that public publication resumes without waiting for contact refresh to complete, remains pending after the publication error, and is retried by normal foreground maintenance without another foreground event or payment. After sign-out or identity replacement, an older refresh must not publish for the new identity. Controlled payment events and publication failures require gates beyond the journey runner capabilities. Hold invoice preparation or the final identity lookup before publication, begin deletion, then release it. New public/private endpoint rotation must stay deferred while matching paid-invoice invalidation and receipt attribution still complete. Already-admitted writes may finish, but an older publication must not acknowledge a newer paid-invoice invalidation. These checks require controlled payment events and SDK-admission gates beyond the journey runner capabilities. @@ -18,7 +18,7 @@ Hold initial session restoration with public contact payments enabled, turn cont Hold endpoint preparation before publication admission, or queue a public publication behind SDK work. Begin deletion, complete endpoint removal while session revocation remains held, then release the queued preparation/publication. The removed endpoints must stay absent. Separately, let a publication enter its SDK write before deletion: cleanup must wait for it and remove its endpoints afterward, even if its caller is cancelled. This requires controlled operation gates beyond the journey runner capabilities. -While deletion is held, make a Lightning channel usable or ready, then fail deletion while retaining the same identity. Public and private endpoint preparation must refresh the invoice rather than reuse its old route hints. A skipped or failed publication must retain that refresh for normal maintenance; a new session must not inherit it. Also hold an older public refresh before publication, pay its invoice and publish the replacement, then release the older refresh. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. +While preparation is held before the durable cleanup checkpoint, make a Lightning channel usable or ready, then fail preparation while retaining the same identity. Public and private endpoint preparation must refresh the invoice rather than reuse its old route hints. A skipped or failed publication must retain that refresh for normal maintenance; a new session must not inherit it. Also hold an older public refresh before publication, pay its invoice and publish the replacement, then release the older refresh. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. Hold initial session restoration before sign-in completes, make a Lightning channel usable or ready, then release restoration while public publication remains blocked. Restore readiness or connectivity without another channel event. Normal public maintenance must retain the forced invoice refresh for the restored identity, including after a deferred or failed restoration and a skipped or failed publication. Work retained for the stored identity or an existing signed-in session must not transfer to a replacement identity or a new session for the same key. These checks require restoration, channel and publication gates beyond the journey runner capabilities. @@ -36,4 +36,32 @@ With an imported grant that cannot be restored and Ring unavailable, Disconnect Profile deletion withdraws private payment lists before removing the profile and performs App Registry reconciliation during final public cleanup. An intermediate registry update is deferred; the existing private cleanup marker stays pending until deletion succeeds or normal cleanup retry completes. Public endpoint removal, capability checks, session revocation and uncertain-write safety waits remain required. -For controlled fault testing, interrupt deletion after private withdrawal but before profile removal, then restart with the same identity. Verify cleanup remains pending, normal retry reconciles the registry, and a failed retry keeps the marker. Also hold a private withdrawal undelivered: it must remain pending, not be acknowledged by the deferred registry step. Check that Disconnect and disabling private sharing still reconcile the registry. These checks require operation gates and network fault injection beyond the journey runner capabilities. +For controlled fault testing, interrupt deletion after private withdrawal but before profile removal, then restart with the same identity. Verify cleanup remains pending and authenticated automatic work does not resume. Review reads the original identity again and requires a new confirmation; Disconnect only completes local cleanup and leaves remote state uncertain. Also hold a private withdrawal undelivered: it must remain pending, not be acknowledged by the deferred registry step. Check that disabling private sharing outside a pending removal still reconciles the registry. Local Disconnect does not make a registry mutation. These checks require operation gates and network fault injection beyond the journey runner capabilities. + +## Interrupted removal and local recovery + +`profile-removal-recovery.xml` specifies the changed failure-dialog and restart routes. The controlled +faults and identity/session interleavings require a disposable authenticated homeserver fixture with +operation gates; these are not supplied by the standard journey runner. Never apply these scenarios +to personal identities or use wallet wipe to prepare the fixture. A pending local obligation is not +proof that remote deletion or revocation succeeded. + +Before confirmation, the app captures the original sign-in, local write generation, native profile +revision and contact set. A confirmation can start once. Review only re-reads the original owner and +opens a new confirmation for unchanged records remaining from the previous confirmation. Added or +changed records stop the retry; records observed absent cannot become targets again in this attempt. +The reduced target set remains in force if the new confirmation is canceled or rejected before +cleanup starts. Absence does not establish which operation removed a record. After local credential +teardown has started, Review is unavailable and +Disconnect completes only local work. A dialog or completion queued for an older identity cannot +operate on, navigate away from or report success for its replacement. Ordinary receive accounting +continues; pending state blocks authenticated setup, automatic publication and complete WALLET +capture rather than substituting stale or empty Paykit data. + +Consumed payment-list versions remain until credential erasure is acknowledged. Failure to remove +credentials, settings, private state, contact attribution or the durable marker cannot report +completion. Pending state survives process restart even when all identity credentials were already +removed; Profile still offers an explicit local Disconnect. Malformed/unavailable marker storage +fails closed. Local success never asserts remote endpoint or profile removal after an uncertain +response. Native key-only contact batches do not establish cross-device edit/re-add/unblock/ABA +protection; that native conditional-mutation work remains separate. diff --git a/journeys/profile/delete-profile-uncertain-response.xml b/journeys/profile/delete-profile-uncertain-response.xml index fe825c36ff..ce66d6666a 100644 --- a/journeys/profile/delete-profile-uncertain-response.xml +++ b/journeys/profile/delete-profile-uncertain-response.xml @@ -12,6 +12,6 @@ Verify failure options are shown and no success notification or onboarding navigation occurs Wait through the network timeout and verify the app starts no second contact cleanup, profile deletion or sign-in Cancel the failure dialog and verify no mutation is retried - Restart the app without choosing Retry and verify it does not automatically repeat the deletion + Restart the app without choosing Review and verify it shows pending local cleanup without automatically repeating deletion or restoring authentication diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index cfb33769e3..1f358add6b 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -15,7 +15,8 @@ Open the existing profile from the Home header Open Edit Profile Scroll to Delete Profile and tap it - Confirm deletion + Wait for profile removal preparation to finish and verify the confirmation dialog appears without deleting the profile + Confirm deletion once Verify a progress indicator is visible while deletion is pending and profile actions cannot be used Wait for deletion to finish and verify profile onboarding is shown Return Home and reopen Profile; verify the deleted profile does not reappear diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index f9fd8c2e29..fb87f94734 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -5,7 +5,8 @@ fixture inject an unknown contact-removal response or a partial removal retaining an active peer. Also inject a profile-deletion error whose message contains 404 Not Found, without confirming deletion. These controls are not available in standard staging. This journey covers stopping on incomplete - cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation. + cleanup; profile-removal-recovery.xml covers explicit recovery. Cross-device conditional contact deletion + requires the separate native contract. Open Edit Profile and confirm deletion with the cleanup fault enabled @@ -13,8 +14,8 @@ Verify a failed private withdrawal is followed by no contact or profile deletion Verify unknown contact removal keeps local contact data and prevents profile deletion Verify partial contact removal preserves contacts and overrides not acknowledged as removed - Verify an uncertain profile-deletion error preserves the session and sharing settings without success feedback + Verify an uncertain profile-deletion error retains recovery information without success feedback and keeps authenticated automatic work paused Cancel the failure dialog and verify the app starts no second deletion - Restart the app and verify no profile deletion is automatically replayed + Restart the app and verify pending local cleanup is shown without automatic profile deletion or authenticated reactivation diff --git a/journeys/profile/profile-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml new file mode 100644 index 0000000000..a9fa35b5bb --- /dev/null +++ b/journeys/profile/profile-removal-recovery.xml @@ -0,0 +1,25 @@ + + + Use a disposable authenticated profile and controlled homeserver/operation/storage faults. + These fault gates are outside the standard Capabilities; no live funds or wallet wipe is needed. + Keep a redacted operation counter. Recreate the fixture separately for each failure checkpoint. + Local Disconnect does not assert remote deletion. Conditional contact deletion across devices is + not proved by local ownership, and requires the separate native CAS fixture. + + + Open Edit Profile, request deletion and verify preparation opens a confirmation without a mutation + Confirm once, lose a mutation acknowledgement and verify failure feedback without success or automatic replay + Tap Review and verify only read-only reconciliation runs before a new confirmation appears + In separate fixtures, edit the profile or change or add a contact after the failure and verify Review rejects the changed targets without a mutation + Review unchanged remaining records, then recreate a record that was observed absent and verify a later Review cannot add it back to this attempt + Cancel that confirmation and verify the mutation count does not increase + Choose Disconnect from the failure options and verify a separate local-only confirmation appears + Cancel local Disconnect and verify neither local cleanup nor remote mutation runs + Confirm local Disconnect while credential erasure is unavailable and verify recovery remains pending without clearing consumed-payment-list safety state + Restore local storage, restart after credential erasure but before local completion and open Profile + Verify pending cleanup and Disconnect remain visible with no stored identity, no authenticated activation and no automatic remote mutation + Confirm local Disconnect and verify all required local cleanup finishes before leaving Profile + In a fresh fixture, replace the sign-in before an old confirmation or completion is delivered and verify it cannot act on or navigate away from the replacement + Open ordinary receive and verify address accounting remains available while profile cleanup is pending + + From ff4dc35e5eb7cb0b6a18d8e9f9d5ece1e9a08536 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 08:37:12 +0200 Subject: [PATCH 2/5] chore: rename changelog fragment --- changelog.d/next/{1687.fixed.md => 1693.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1687.fixed.md => 1693.fixed.md} (100%) diff --git a/changelog.d/next/1687.fixed.md b/changelog.d/next/1693.fixed.md similarity index 100% rename from changelog.d/next/1687.fixed.md rename to changelog.d/next/1693.fixed.md From 7e2e466cafd10bc0883afe0630bfc094cb8c65be Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 10:23:08 +0200 Subject: [PATCH 3/5] fix: complete profile removal recovery safely --- .../profile/ProfileRecoveryContentTest.kt | 9 ++- .../to/bitkit/data/ProfileRemovalStore.kt | 15 ++--- .../bitkit/repositories/PrivatePaykitRepo.kt | 8 +++ .../java/to/bitkit/repositories/PubkyRepo.kt | 7 +- .../java/to/bitkit/repositories/WalletRepo.kt | 16 +++-- .../to/bitkit/services/PaykitSdkService.kt | 3 +- .../screens/profile/EditProfileViewModel.kt | 13 +++- .../ui/screens/profile/ProfileScreen.kt | 7 +- .../ui/screens/profile/ProfileViewModel.kt | 7 +- .../usecases/ProfileRemovalCoordinator.kt | 7 +- .../to/bitkit/usecases/WipeWalletUseCase.kt | 11 +++- .../AppWidgetRefreshSchedulerTest.kt | 3 +- .../appwidget/AppWidgetRefreshWorkerTest.kt | 3 +- .../to/bitkit/build/PubkyAuthManifestTest.kt | 8 ++- .../to/bitkit/data/ProfileRemovalStoreTest.kt | 32 +++++++++- .../to/bitkit/repositories/BackupRepoTest.kt | 54 ++++++++++++++++ .../repositories/PrivatePaykitRepoTest.kt | 5 ++ .../to/bitkit/repositories/PubkyRepoTest.kt | 47 +++++++++++++- .../to/bitkit/repositories/WalletRepoTest.kt | 14 ++++ .../bitkit/services/PaykitSdkServiceTest.kt | 16 +++++ .../profile/EditProfileViewModelTest.kt | 31 +++++++-- .../screens/profile/ProfileViewModelTest.kt | 24 +++++++ .../usecases/ProfileRemovalCoordinatorTest.kt | 64 ++++++++++++++++--- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 9 +-- 24 files changed, 362 insertions(+), 51 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt index 7c67be5946..c6a89d8da8 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso.pressBackUnconditionally import androidx.test.platform.app.InstrumentationRegistry import org.junit.Rule import org.junit.Test @@ -30,12 +31,13 @@ class ProfileRecoveryContentTest { fun restartRecoveryWithoutCredentialsRequiresLocalConfirmation() { var state by mutableStateOf(ProfileUiState(recoveryPending = true, localDisconnect = true)) var confirmations = 0 + var back = 0 compose.setContent { AppThemeSurface { CompositionLocalProvider(LocalInspectionMode provides true) { ProfileContent( uiState = state, - onBackClick = {}, + onBackClick = { back++ }, onClickEdit = { error("Pending recovery must not offer editing") }, onClickCopy = {}, onClickShare = {}, @@ -67,6 +69,11 @@ class ProfileRecoveryContentTest { compose.onNodeWithTag("DialogConfirm").performClick() compose.runOnIdle { assertEquals(1, confirmations) } compose.onNodeWithTag("ProfileRecoveryDisconnect").assertIsNotEnabled() + compose.onNodeWithTag("NavigationBack").performClick() + compose.onNodeWithTag("HeaderMenu").assertDoesNotExist() + pressBackUnconditionally() + compose.onNodeWithTag("ProfileRecoveryDisconnect").assertIsDisplayed() + compose.runOnIdle { assertEquals(0, back) } } @Test diff --git a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt index b0cc7033a7..8e4b775722 100644 --- a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt +++ b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -73,14 +72,9 @@ class ProfileRemovalStore internal constructor(private val store: DataStore emit(true) - if (attempt < 3) { - delay((250L shl attempt.toInt()).milliseconds) - true - } else { - false - } + delay((250L shl attempt.coerceAtMost(8).toInt()).coerceAtMost(60_000).milliseconds) + true } - .catch { emit(true) } } internal suspend fun pendingAttempt(): String? = store.data.first().attemptId @@ -195,6 +189,7 @@ internal object ProfileRemovalSerializer : Serializer { } } -internal class ProfileRemovalPending : AppError( - "Profile cleanup is pending. Disconnect the profile to finish local recovery." +internal class ProfileRemovalPending(cause: Throwable? = null) : AppError( + "Profile cleanup is pending. Disconnect the profile to finish local recovery.", + cause, ) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 6ab085472a..04078a464d 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -587,6 +587,14 @@ class PrivatePaykitRepo @Inject constructor( } } + /** Retry a complete WALLET capture only after durable removal recovery has been acknowledged. */ + internal suspend fun notifyProfileRemovalCompleted(pause: ProfileDeletionPause) = withContext( + serializedDispatcher + ) { + check(pause in profileDeletionPauses) { "Profile removal has already finished" } + notifyBackupStateChanged() + } + private suspend fun clearLocalPaymentState() { clearPendingMessageDrainRetries() knownSavedContactKeys.clear() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 784f1e0c6e..18bc8ec910 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -376,7 +376,7 @@ class PubkyRepo @Inject constructor( _sessionRestorationFailed.value = true runSuspendCatching { pubkyService.preparePublicProfileRecovery() } .onFailure { Logger.warn("Failed to prepare public profile access", it, context = TAG) } - throw failure + throw if (failure is ProfileRemovalPending) failure else ProfileRemovalPending(failure) } } @@ -1973,6 +1973,11 @@ class PubkyRepo @Inject constructor( clearProfileMetadataAfterCredentialTeardown() } + internal suspend fun notifyProfileRemovalCompleted() { + identityOperations.requireRemoval() + notifyBackupStateChanged() + } + /** The wallet wipe has acknowledged keychain deletion and still owns both teardown barriers. */ internal suspend fun finishProfileWipeLocally() { identityOperations.requireWipe() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c7c9173a01..b1bb744dad 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -48,7 +48,6 @@ import to.bitkit.models.toDerivationPath import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase -import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -642,12 +641,15 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - wipeWalletUseCase( - walletIndex = walletIndex, - resetWalletState = ::resetState, - onSuccess = ::setWalletExistsState, - ).onFailure { - if (it is WipeIncomplete) setWalletExistsState() + try { + wipeWalletUseCase( + walletIndex = walletIndex, + resetWalletState = ::resetState, + onSuccess = {}, + ) + } finally { + runCatching { setWalletExistsState() } + .onFailure { Logger.warn("Failed to refresh wallet existence after wipe", it, context = TAG) } } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 713f3c5111..e1a18f4e04 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1692,7 +1692,8 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { initialize() } .onFailure { Logger.warn("Failed to initialize Paykit before wallet wipe", it, context = TAG) - preparePublicProfileRecovery() + runSuspendCatching { preparePublicProfileRecovery() } + .onFailure { Logger.warn("Failed to prepare public access before wallet wipe", it, context = TAG) } } return operationLock.withWalletWipe { // Removal has drained. Bootstrap publication never waits for the SDK lock. 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 c99f3aa71f..ffe9b84561 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 @@ -298,7 +298,18 @@ class EditProfileViewModel @Inject constructor( removalCoordinator.remove(confirmation).onSuccess { endedOwner -> removalFailure = null if (removalCoordinator.isCurrent(endedOwner)) { - _effects.emit(EditProfileEffect.RemovalCompleted(endedOwner)) + val successToast = if (confirmation.localOnly) { + ProfileRemoval.UNLINK.successToast + } else { + _uiState.value.removal.successToast + } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(successToast), + ) + if (removalCoordinator.isCurrent(endedOwner)) { + _effects.emit(EditProfileEffect.RemovalCompleted(endedOwner)) + } } }.onFailure { error -> removalFailure = (error as? ProfileRemovalFailure) ?: removalFailure 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 f46736a047..221769a74c 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.profile +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -127,13 +128,15 @@ internal fun ProfileContent( onSaveTag: (String) -> Unit, onDismissCopiedPopup: () -> Unit, ) { + BackHandler(enabled = uiState.isSigningOut) {} + val currentProfile = uiState.profile val cachedProfile = uiState.cachedProfile val topBar = @Composable { modifier: Modifier -> AppTopBar( titleText = stringResource(R.string.profile__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, + onBackClick = { if (!uiState.isSigningOut) onBackClick() }, + actions = { if (!uiState.isSigningOut) DrawerNavIcon() }, modifier = modifier, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index abb6c6af76..f8465e7044 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -147,7 +147,12 @@ class ProfileViewModel @Inject constructor( fun showSignOutConfirmation() { if (_isSigningOut.value) return - val owner = uiState.value.owner ?: return + val state = uiState.value + val owner = if (state.recoveryPending) { + pubkyRepo.profileOwner().takeIf { it.signIn == null } ?: state.owner + } else { + state.owner + } ?: return val revision = ++preparationRevision _isSigningOut.value = true viewModelScope.launch { diff --git a/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt b/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt index a6c1757934..bf6e1c7a0e 100644 --- a/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt +++ b/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt @@ -118,12 +118,14 @@ class ProfileRemovalCoordinator @Inject constructor( sharingRepo.withProfileRemoval { pubkyRepo.performProfileRemoval(confirmation.owner, confirmation.attemptId) { var localCompletionStarted = false + var removalMayHaveStarted = removalStore.pendingAttempt() != null try { check(confirmation.deletion == null || confirmation.reviewCeiling.get() === confirmation.deletion) { "This confirmation was replaced by a later review" } privatePaykitRepo.withProfileDeletionPaused { pause -> confirmation.deletion?.let { pubkyRepo.validateProfileDeletion(it) } + removalMayHaveStarted = true removalStore.begin(confirmation.attemptId) if (!confirmation.localOnly) { privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow() @@ -136,12 +138,15 @@ class ProfileRemovalCoordinator @Inject constructor( withContext(NonCancellable) { privatePaykitRepo.finishProfileRemoval(pause).getOrThrow() removalStore.complete(confirmation.attemptId) + privatePaykitRepo.notifyProfileRemovalCompleted(pause) + pubkyRepo.notifyProfileRemovalCompleted() } } } catch (error: CancellationException) { - captureFailure(error, confirmation, localCompletionStarted) + if (removalMayHaveStarted) captureFailure(error, confirmation, localCompletionStarted) throw error } catch (error: Throwable) { + if (!removalMayHaveStarted) throw error throw captureFailure(error, confirmation, localCompletionStarted) } }.getOrThrow() diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 4262c8933c..fc39095026 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -66,6 +66,7 @@ class WipeWalletUseCase @Inject constructor( if (!wipeMutex.tryLock()) return Result.failure(WipeAlreadyInProgress()) backupRepo.setWiping(true) lightningRepo.setWiping(true) + var credentialsMayBeErased = false val result = try { runSuspendCatching { contactPaymentSettingsRepo.get().withWalletWipe(pubkyRepo::withWalletWipeAdmission) { @@ -77,7 +78,7 @@ class WipeWalletUseCase @Inject constructor( stopNode().getOrThrow() profileRemovalStore.begin(attemptId) if (pending == null) cleanupRemote() - wipeLocal(walletIndex, pause).getOrThrow() + wipeLocal(walletIndex, pause) { credentialsMayBeErased = true }.getOrThrow() profileRemovalStore.complete(attemptId) resetWalletState() onSuccess() @@ -85,6 +86,9 @@ class WipeWalletUseCase @Inject constructor( } } } + }.let { outcome -> + val error = outcome.exceptionOrNull() + if (error != null && credentialsMayBeErased) Result.failure(WipeIncomplete(error)) else outcome } } finally { lightningRepo.setWiping(false) @@ -112,12 +116,13 @@ class WipeWalletUseCase @Inject constructor( private suspend fun wipeLocal( walletIndex: Int, pause: PrivatePaykitRepo.ProfileDeletionPause, + onCredentialWipe: () -> Unit, ): Result { lightningRepo.wipeStorage(walletIndex).onFailure { return Result.failure(it) } step("clear post-migration sync flag") { migrationService.setNeedsPostMigrationSync(false) } step("clear migration data") { migrationService.cleanupAfterMigration() } - runSuspendCatching { usdtRepo.wipe { keychain.wipe() }.getOrThrow() } - .getOrElse { return Result.failure(WipeIncomplete(it)) } + onCredentialWipe() + usdtRepo.wipe { keychain.wipe() }.getOrThrow() paykitUsdtPaymentRepo.resetForWipe() // Consumption watermarks survive until the keychain wipe is acknowledged. Pending remains on any failure. privatePaykitRepo.get().finishProfileRemoval(pause).getOrElse { return Result.failure(it) } diff --git a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt index 4e0ff760e7..e999472ed8 100644 --- a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt @@ -1,6 +1,7 @@ package to.bitkit.appwidget import android.app.AlarmManager +import android.app.Application import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -20,7 +21,7 @@ import to.bitkit.appwidget.model.AppWidgetType import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds -@Config(sdk = [34]) +@Config(application = Application::class, sdk = [34]) @RunWith(RobolectricTestRunner::class) class AppWidgetRefreshSchedulerTest { private val context = ApplicationProvider.getApplicationContext() diff --git a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt index 1189084e03..74860a7d31 100644 --- a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt +++ b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt @@ -1,5 +1,6 @@ package to.bitkit.appwidget +import android.app.Application import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.work.WorkerParameters @@ -38,7 +39,7 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalTime::class) -@Config(sdk = [34]) +@Config(application = Application::class, sdk = [34]) @RunWith(RobolectricTestRunner::class) class AppWidgetRefreshWorkerTest : BaseUnitTest() { private val context = ApplicationProvider.getApplicationContext() diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt index 8354250793..26706fb82d 100644 --- a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt +++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt @@ -121,7 +121,13 @@ class PubkyAuthManifestTest { .setPackage(packageName) val resolved = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) if (alias == null) { - assertTrue(resolved.isEmpty(), it) + val actual = resolved.map { result -> + val activity = result.activityInfo + val component = ComponentName(packageName, activity.name) + val override = packageManager.getComponentEnabledSetting(component) + "${activity.name}: enabled=${activity.enabled}, override=$override" + } + assertTrue(resolved.isEmpty(), "$it resolved to $actual") } else { val activity = resolved.single().activityInfo assertEquals(alias.className, activity.name, it) diff --git a/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt b/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt index 476cc1375d..31d0581525 100644 --- a/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt @@ -37,7 +37,7 @@ class ProfileRemovalStoreTest { val folder = TemporaryFolder() @Test - fun `observation fails closed with bounded retries and restarts after explicit durable completion`() = runTest { + fun `observation fails closed with backoff and restarts after explicit durable completion`() = runTest { val id = UUID.randomUUID().toString() val state = MutableStateFlow(ProfileRemovalState(attemptId = id)) var unavailable = true @@ -54,7 +54,7 @@ class ProfileRemovalStoreTest { val observed = mutableListOf() backgroundScope.launch { store.pending.collect { observed += it } } runCurrent() - advanceTimeBy(10_000) + advanceTimeBy(1_750) runCurrent() assertEquals(4, reads) assertEquals(true, observed.last()) @@ -65,6 +65,34 @@ class ProfileRemovalStoreTest { assertNull(store.pendingAttempt()) } + @Test + fun `observation recovers from prolonged read errors without a cleanup mutation`() = runTest { + var available = false + var reads = 0 + val store = ProfileRemovalStore(object : DataStore { + override val data = flow { + reads++ + if (!available) throw IOException("Local storage unavailable") + emit(ProfileRemovalState()) + } + override suspend fun updateData( + transform: suspend (ProfileRemovalState) -> ProfileRemovalState + ): ProfileRemovalState = + error("Read-only recovery must not mutate cleanup state") + }) + val observed = mutableListOf() + backgroundScope.launch { store.pending.collect { observed += it } } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + assertEquals(true, observed.last()) + available = true + advanceTimeBy(60_000) + runCurrent() + assertEquals(false, observed.last()) + assertTrue(reads <= 12, "Local recovery must retain capped backoff") + } + @Test fun `restart retains pending without authorizing cleanup or another attempt`() = runTest { val file = folder.newFile("removal").also { it.delete() } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 188ae1f8c2..adb0281473 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -41,6 +41,7 @@ import to.bitkit.data.BlocktankRefundAddress import to.bitkit.data.CacheStore import to.bitkit.data.HwWalletData import to.bitkit.data.HwWalletStore +import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.WatchOnlyAccountAllocationState @@ -73,6 +74,7 @@ import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.util.UUID import javax.inject.Provider import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals @@ -1235,6 +1237,58 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `acknowledged removal wakes the suppressed wallet category after delayed completion`() = test { + val removal = ProfileRemovalStore.inMemoryForTesting() + val attempt = UUID.randomUUID().toString() + removal.withAccess(attempt) { removal.begin(attempt) } + whenever(clock.now()).thenAnswer { Instant.fromEpochMilliseconds(3_000 + currentTime) } + val statuses = MutableStateFlow( + mapOf(BackupCategory.WALLET to BackupItemStatus(synced = 1_000, required = 2_000)) + ) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + stubMetadataBackupReads() + val metadataVersion = MutableStateFlow(0L) + val walletVersion = MutableStateFlow(0L) + whenever(pubkyRepo.backupStateVersion).thenReturn(metadataVersion) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(walletVersion) + whenever { privatePaykitRepo.backupSnapshot() }.doSuspendableAnswer { + runCatching { + removal.requireNoPending() + PrivatePaykitBackupSnapshot(null, 0) + } + } + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + verify(privatePaykitRepo, times(1)).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + advanceTimeBy(20_000) + runCurrent() + verify(privatePaykitRepo, times(1)).backupSnapshot() + removal.withAccess(attempt) { removal.complete(attempt) } + metadataVersion.value++ + runCurrent() + advanceTimeBy(5_000) + runCurrent() + // Pubky metadata notifications cannot release a failed WALLET capture. + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + assertTrue(statuses.value.getValue(BackupCategory.WALLET).isRequired) + walletVersion.value++ + runCurrent() + advanceTimeBy(5_000) + runCurrent() + verify(privatePaykitRepo, times(2)).backupSnapshot() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + assertFalse(statuses.value.getValue(BackupCategory.WALLET).isRequired) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `wallet backup retains pending work while profile removal owns the SDK`() = test { whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 2f3bbd7bbb..9db8bea722 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -362,10 +362,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `profile removal finalization is local and rejects an ended pause`() = test { val pause = sut.beginProfileDeletion() assertTrue(sut.finishProfileRemoval(pause).isSuccess) + val beforeCompletion = sut.backupStateVersion.value + sut.notifyProfileRemovalCompleted(pause) + assertEquals(beforeCompletion + 1, sut.backupStateVersion.value) verify(paykitSdkService, never()).clearState() sut.endProfileDeletion(pause) clearInvocations(cacheStore) assertTrue(sut.finishProfileRemoval(pause).isFailure) + assertFailsWith { sut.notifyProfileRemovalCompleted(pause) } + assertEquals(beforeCompletion + 1, sut.backupStateVersion.value) verify(cacheStore, never()).reset() } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4f753fd1eb..b9c7dfc482 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import androidx.datastore.core.DataStore import app.cash.turbine.test import coil3.ImageLoader import coil3.disk.DiskCache @@ -39,6 +40,7 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.job import kotlinx.coroutines.launch @@ -66,6 +68,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import org.mockito.verification.VerificationMode +import to.bitkit.data.ProfileRemovalState import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyCachedProfile import to.bitkit.data.PubkyImageCacheEpoch @@ -93,6 +96,7 @@ import to.bitkit.test.forEachCase import to.bitkit.usecases.ProfileRemovalCoordinator import to.bitkit.usecases.ProfileRemovalFailure import to.bitkit.utils.AppError +import java.io.IOException import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -211,6 +215,42 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.performProfileRemoval(original) { error("obsolete owner admitted") }.isFailure) } + @Test + fun `completed removal schedules a new backup under its original admission`() = test { + advanceUntilIdle() + whenever { pubkyService.withProfileRemoval(any(), any()) }.doSuspendableAnswer { + it.getArgument Unit>(1).invoke() + } + val version = sut.backupStateVersion.value + sut.performProfileRemoval(sut.profileOwner()) { + sut.notifyProfileRemovalCompleted() + }.getOrThrow() + assertEquals(version + 1, sut.backupStateVersion.value) + assertFailsWith { sut.notifyProfileRemovalCompleted() } + } + + @Test + fun `unreadable removal state fails restoration closed without escaping resume`() = test { + var unavailable = true + val store = ProfileRemovalStore(object : DataStore { + override val data = flow { + if (unavailable) throw IOException("Local read unavailable") + emit(ProfileRemovalState()) + } + override suspend fun updateData( + transform: suspend (ProfileRemovalState) -> ProfileRemovalState, + ): ProfileRemovalState = error("Resume must not change recovery state") + }) + val resumed = createSut(removalStore = store) + try { + assertFalse(resumed.restoreSessionIfNeeded()) + assertFalse(resumed.isRestoringSession.value) + assertTrue(resumed.sessionRestorationFailed.value) + } finally { + unavailable = false + } + } + @Test fun `pending removal restart refuses activation and settles restoration without clearing credentials`() = test { advanceUntilIdle() @@ -355,7 +395,10 @@ class PubkyRepoTest : BaseUnitTest() { sut = createSut() } - private fun createSut(httpClient: HttpClient = mock()) = PubkyRepo( + private fun createSut( + httpClient: HttpClient = mock(), + removalStore: ProfileRemovalStore = profileRemovalStore, + ) = PubkyRepo( ioDispatcher = testDispatcher, pubkyService = pubkyService, keychain = keychain, @@ -366,7 +409,7 @@ class PubkyRepoTest : BaseUnitTest() { settingsStore = settingsStore, httpClient = httpClient, clock = clock, - profileRemovalStore = profileRemovalStore, + profileRemovalStore = removalStore, ) private fun PubkyRepo.removalCoordinatorForTest(): ProfileRemovalCoordinator { diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e4506b8a6b..c43fa58c3d 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -58,6 +58,7 @@ import java.util.ArrayDeque import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -1700,6 +1701,19 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(sut.walletState.value.walletExists) } + @Test + fun `canceled wipe refreshes acknowledged credential erasure without claiming completion`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + sut.setWalletExistsState() + assertTrue(sut.walletState.value.walletExists) + whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenAnswer { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(false) + throw CancellationException("Canceled after credential erasure") + } + assertFailsWith { sut.wipeWallet() } + assertFalse(sut.walletState.value.walletExists) + } + @Test fun `wipeWallet should return failure when use case fails`() = test { whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenReturn(Result.failure(error)) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 85123ce5d2..6470453f3d 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2180,6 +2180,22 @@ class PaykitSdkServiceTest { } } + @Test + fun `wallet wipe remains available when platform initialization keeps failing`() = runTest { + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = { throw AppError("Native setup unavailable") }, + settingsStore = mock(), + sdkFactory = { error("A failed setup must not create an SDK handle") }, + ) + var localCleanup = false + service.withWalletWipe { localCleanup = true } + assertTrue(localCleanup) + } + @Test fun `wallet wipe drains initialization before cleanup and allows fresh work`() = runTest { val sdk = mock() 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 627e4ffd85..ae6f266b14 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 @@ -19,12 +19,15 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink +import to.bitkit.models.Toast import to.bitkit.repositories.PubkyProfileOwner import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PubkySignIn import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -64,6 +67,21 @@ class EditProfileViewModelTest : BaseUnitTest() { assertNull(fixture.store.pendingAttempt()) } + @Test + fun `completed removal shows success only for its ended owner`() = test { + val sut = createSut() + advanceUntilIdle() + sut.showDeleteConfirmation() + advanceUntilIdle() + ToastEventBus.events.test { + sut.deleteProfile() + advanceUntilIdle() + val toast = expectMostRecentItem() + assertEquals(Toast.ToastType.SUCCESS, toast.type) + assertEquals("Profile deleted", toast.title) + } + } + @Test fun `uncertain deletion review reads state and requires fresh confirmation`() = test { val sut = createSut() @@ -101,10 +119,13 @@ class EditProfileViewModelTest : BaseUnitTest() { assertTrue(sut.uiState.value.showDeleteDialog) assertTrue(sut.uiState.value.isLocalDisconnect) verify(pubkyRepo, never()).finishProfileRemovalLocally() - sut.effects.test { - sut.deleteProfile() - advanceUntilIdle() - assertIs(awaitItem()) + ToastEventBus.events.test { + sut.effects.test { + sut.deleteProfile() + advanceUntilIdle() + assertIs(awaitItem()) + } + assertEquals("Pubky unlinked", expectMostRecentItem().title) } verify(pubkyRepo, times(1)).deleteProfile(fixture.deletion) verify(pubkyRepo, never()).signOutForProfileRemoval() @@ -374,6 +395,8 @@ class EditProfileViewModelTest : BaseUnitTest() { ringCheck: CompletableDeferred? = null, ): EditProfileViewModel { whenever(context.getString(any())).thenReturn("") + whenever(context.getString(R.string.profile__delete_success)).thenReturn("Profile deleted") + whenever(context.getString(R.string.profile__unlink_success)).thenReturn("Pubky unlinked") if (ringCheck != null) { whenever { pubkyRepo.isRingAdoptedIdentity() }.doSuspendableAnswer { ringCheck.await() } } else { diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 70211e7042..e90fbd80a9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -441,6 +441,30 @@ class ProfileViewModelTest : BaseUnitTest() { assertNull(fixture.store.pendingAttempt()) } + @Test + fun `existing profile screen can finish its ended profile cleanup locally`() = test { + val pending = MutableStateFlow(false) + val sut = createSut(recoveryPending = pending) + advanceUntilIdle() + sut.uiState.test { + awaitItem() + val id = UUID.randomUUID().toString() + fixture.store.withAccess(id) { fixture.store.begin(id) } + fixture.current = fixture.ended + pending.value = true + advanceUntilIdle() + sut.showSignOutConfirmation() + advanceUntilIdle() + assertTrue(sut.uiState.value.showSignOutDialog) + assertTrue(sut.uiState.value.localDisconnect) + sut.signOut() + advanceUntilIdle() + assertNull(fixture.store.pendingAttempt()) + verify(pubkyRepo, never()).signOutForProfileRemoval() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `sign out waits for confirmation and delayed effect is bound to ended owner`() = test { val sut = createSut(createProfile()) diff --git a/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt b/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt index a573bb22f7..e8765fed77 100644 --- a/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt @@ -101,6 +101,16 @@ class ProfileRemovalCoordinatorTest : BaseUnitTest() { events += "local credentials and metadata" current = ended } + whenever { privateRepo.notifyProfileRemovalCompleted(pause) }.doSuspendableAnswer { + assertNull(store.pendingAttempt()) + events += "wallet backup ready" + Unit + } + whenever { pubky.notifyProfileRemovalCompleted() }.doSuspendableAnswer { + assertNull(store.pendingAttempt()) + events += "backup ready" + Unit + } whenever { privateRepo.finishProfileRemoval(pause) }.doSuspendableAnswer { assertNotNull(store.pendingAttempt()) events += "private local" @@ -121,7 +131,9 @@ class ProfileRemovalCoordinatorTest : BaseUnitTest() { "profile", "revoke", "local credentials and metadata", - "private local" + "private local", + "wallet backup ready", + "backup ready" ), events ) @@ -130,6 +142,38 @@ class ProfileRemovalCoordinatorTest : BaseUnitTest() { assertTrue(sut.isCurrent(ended)) } + @Test + fun `backup readiness waits for delayed durable marker completion`() = test { + val data = MutableStateFlow(ProfileRemovalState()) + val completing = CompletableDeferred() + val release = CompletableDeferred() + store = ProfileRemovalStore(object : DataStore { + override val data = data + override suspend fun updateData( + transform: suspend (ProfileRemovalState) -> ProfileRemovalState + ): ProfileRemovalState { + val next = transform(data.value) + if (next.attemptId == null) { + completing.complete(Unit) + release.await() + } + data.value = next + return next + } + }) + val confirmation = sut.prepareDeletion(original).getOrThrow() + val removal = async { sut.remove(confirmation) } + completing.await() + assertEquals(confirmation.attemptId, store.pendingAttempt()) + verify(privateRepo, never()).notifyProfileRemovalCompleted(any()) + verify(pubky, never()).notifyProfileRemovalCompleted() + release.complete(Unit) + assertTrue(removal.await().isSuccess) + verify(privateRepo).notifyProfileRemovalCompleted(pause) + verify(pubky).notifyProfileRemovalCompleted() + assertNull(store.pendingAttempt()) + } + @Test fun `one confirmation cannot repeat an uncertain profile mutation`() = test { whenever(pubky.deleteProfile(deletion)).thenReturn(Result.failure(IllegalStateException("lost response"))) @@ -307,16 +351,16 @@ class ProfileRemovalCoordinatorTest : BaseUnitTest() { } @Test - fun `explicit local disconnect remains available after precondition failure without a marker`() = test { + fun `changed confirmation before any cleanup permits a fresh deletion without uncertainty`() = test { whenever { pubky.validateProfileDeletion(deletion) }.thenAnswer { error("profile changed") } - val failure = assertIs( - sut.remove(sut.prepareDeletion(original).getOrThrow()).exceptionOrNull() - ) - assertNull(store.pendingAttempt()) - assertTrue(sut.remove(sut.prepareLocalDisconnect(failure).getOrThrow()).isSuccess) + val confirmation = sut.prepareDeletion(original).getOrThrow() + val failure = sut.remove(confirmation).exceptionOrNull() + assertIs(failure) + assertNull(sut.failureFor(confirmation)) assertNull(store.pendingAttempt()) verify(pubky, never()).deleteProfile(any()) - verify(pubky, never()).signOutForProfileRemoval() + verify(privateRepo, never()).removePublishedEndpointsForCleanup(any(), any()) + assertTrue(sut.prepareDeletion(original).isSuccess) } @Test @@ -389,8 +433,12 @@ class ProfileRemovalCoordinatorTest : BaseUnitTest() { assertSame(ended, failure.recoveryOwner) assertEquals(confirmation.attemptId, store.pendingAttempt()) assertTrue(sut.reviewAgain(failure).isFailure) + verify(privateRepo, never()).notifyProfileRemovalCompleted(any()) + verify(pubky, never()).notifyProfileRemovalCompleted() rejectCompletion = false assertTrue(sut.remove(sut.prepareLocalDisconnect(failure).getOrThrow()).isSuccess) + verify(privateRepo).notifyProfileRemovalCompleted(pause) + verify(pubky).notifyProfileRemovalCompleted() assertNull(store.pendingAttempt()) verify(pubky, times(1)).deleteProfile(deletion) verify(pubky, times(1)).signOutForProfileRemoval() diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 5a67ada663..6a54104208 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -310,7 +310,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertEquals(failure.message, result.exceptionOrNull()?.message) + assertEquals(failure.message, assertIs(result.exceptionOrNull()).cause?.message) verify(keychain).wipe() verify(privatePaykitRepo).finishProfileRemoval(profilePause) verify(privatePaykitAddressReservationRepo, never()).clear() @@ -329,7 +329,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertEquals(failure.message, result.exceptionOrNull()?.message) + assertEquals(failure.message, assertIs(result.exceptionOrNull()).cause?.message) verify(keychain).wipe() verify(pubkyRepo).finishProfileWipeLocally() verify(privatePaykitAddressReservationRepo).clear() @@ -448,7 +448,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isFailure) + assertEquals("clear failed", assertIs(result.exceptionOrNull()).cause?.message) verify(keychain).wipe() assertFalse(onSetWalletExistsStateCalled) assertFalse(onWipeCalled) @@ -465,7 +465,8 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertEquals(error, assertIs(result.exceptionOrNull()).cause) + val failure = assertIs(result.exceptionOrNull()) + assertTrue(generateSequence(failure.cause) { it.cause }.any { it === error }) verify(lightningRepo).wipeStorage(0) verify(db, never()).clearAllTables() verify(settingsStore, never()).reset() From 7e2fd14f0ba6d5cab66c98401abc813dc6a05a0b Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 19:58:10 +0200 Subject: [PATCH 4/5] fix: keep pending profile cleanup distinct from expiry --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 2 +- .../java/to/bitkit/repositories/PubkyRepoTest.kt | 12 +++++++++--- changelog.d/next/1693.fixed.md | 2 +- journeys/profile/profile-removal-recovery.xml | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 18bc8ec910..b809e80dd2 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -373,7 +373,7 @@ class PubkyRepo @Inject constructor( runSuspendCatching { profileRemovalStore.requireNoPending() }.exceptionOrNull()?.let { failure -> // Retained credentials must not activate while local teardown is unresolved or unreadable. _isRestoringSession.value = false - _sessionRestorationFailed.value = true + _sessionRestorationFailed.update { false } runSuspendCatching { pubkyService.preparePublicProfileRecovery() } .onFailure { Logger.warn("Failed to prepare public profile access", it, context = TAG) } throw if (failure is ProfileRemovalPending) failure else ProfileRemovalPending(failure) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index b9c7dfc482..a2e892d1f2 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -245,7 +245,8 @@ class PubkyRepoTest : BaseUnitTest() { try { assertFalse(resumed.restoreSessionIfNeeded()) assertFalse(resumed.isRestoringSession.value) - assertTrue(resumed.sessionRestorationFailed.value) + assertFalse(resumed.sessionRestorationFailed.value) + assertTrue(resumed.requiresProfileRecovery()) } finally { unavailable = false } @@ -261,8 +262,13 @@ class PubkyRepoTest : BaseUnitTest() { val restarted = createSut() advanceUntilIdle() assertFalse(restarted.isRestoringSession.value) - assertTrue(restarted.sessionRestorationFailed.value) - assertFalse(restarted.restoreSessionIfNeeded()) + assertFalse(restarted.sessionRestorationFailed.value) + repeat(3) { + restarted.clearSessionRestorationFailed() + assertFalse(restarted.restoreSessionIfNeeded()) + assertFalse(restarted.sessionRestorationFailed.value) + assertTrue(restarted.requiresProfileRecovery()) + } assertEquals(PubkyIdentityReadiness.RecoveryRequired, restarted.awaitIdentityReady()) assertTrue(restarted.initialize().isFailure) verify(pubkyService, never()).initialize() diff --git a/changelog.d/next/1693.fixed.md b/changelog.d/next/1693.fixed.md index c0a4d618a7..83a3879057 100644 --- a/changelog.d/next/1693.fixed.md +++ b/changelog.d/next/1693.fixed.md @@ -1 +1 @@ -Keep interrupted profile removal recoverable and prevent retries or delayed confirmations from changing a replacement identity. +Keep interrupted profile removal recoverable with the correct cleanup guidance and prevent retries or delayed confirmations from changing a replacement identity. diff --git a/journeys/profile/profile-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml index a9fa35b5bb..adcb0a4176 100644 --- a/journeys/profile/profile-removal-recovery.xml +++ b/journeys/profile/profile-removal-recovery.xml @@ -18,6 +18,7 @@ Confirm local Disconnect while credential erasure is unavailable and verify recovery remains pending without clearing consumed-payment-list safety state Restore local storage, restart after credential erasure but before local completion and open Profile Verify pending cleanup and Disconnect remain visible with no stored identity, no authenticated activation and no automatic remote mutation + Background and foreground the app, then reconnect the test device network; verify cleanup guidance remains available without an expired-session or reconnect-profile error Confirm local Disconnect and verify all required local cleanup finishes before leaving Profile In a fresh fixture, replace the sign-in before an old confirmation or completion is delivered and verify it cannot act on or navigate away from the replacement Open ordinary receive and verify address accounting remains available while profile cleanup is pending From e6a75f0e7f5e22d7f4f5ec23d8109b6107f1c9bd Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 01:47:14 +0200 Subject: [PATCH 5/5] fix: preserve receipt and recovery state through cleanup --- .../profile/ProfileRecoveryContentTest.kt | 7 +- .../to/bitkit/data/ProfileRemovalStore.kt | 14 ++- .../to/bitkit/repositories/LightningRepo.kt | 14 ++- .../PrivatePaykitAddressReservationRepo.kt | 14 ++- .../PrivatePaykitContactResolver.kt | 8 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 3 + .../ui/screens/profile/ProfileScreen.kt | 24 ++++-- .../ui/screens/profile/ProfileViewModel.kt | 19 ++++- .../to/bitkit/usecases/WipeWalletUseCase.kt | 4 +- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/data/ProfileRemovalStoreTest.kt | 7 ++ .../bitkit/repositories/LightningRepoTest.kt | 41 +++++++++ ...PrivatePaykitAddressReservationRepoTest.kt | 35 ++++++++ .../PrivatePaykitContactResolverTest.kt | 2 + .../ActivityServicePaykitContactsTest.kt | 28 ++++++ .../screens/profile/ProfileViewModelTest.kt | 22 +++++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 85 ++++++++++++++++++- changelog.d/next/1693.fixed.md | 2 +- 33 files changed, 315 insertions(+), 30 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt index c6a89d8da8..d4fa93388a 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/profile/ProfileRecoveryContentTest.kt @@ -29,7 +29,9 @@ class ProfileRecoveryContentTest { @Test fun restartRecoveryWithoutCredentialsRequiresLocalConfirmation() { - var state by mutableStateOf(ProfileUiState(recoveryPending = true, localDisconnect = true)) + var state by mutableStateOf( + ProfileUiState(recoveryPending = true, localDisconnect = true), + ) var confirmations = 0 var back = 0 compose.setContent { @@ -57,6 +59,9 @@ class ProfileRecoveryContentTest { } } } + compose.onNodeWithText(context.getString(R.string.profile__wallet_recovery_pending)).assertIsDisplayed() + compose.onNodeWithTag("ProfileRecoveryDisconnect").assertDoesNotExist() + compose.runOnIdle { state = state.copy(recoveryCanDisconnect = true) } compose.onNodeWithText(context.getString(R.string.profile__cleanup_pending)).assertIsDisplayed() compose.onNodeWithTag("ProfileRecoveryDisconnect").assertIsDisplayed().performClick() compose.onNodeWithText(context.getString(R.string.profile__disconnect_local_description)).assertIsDisplayed() diff --git a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt index b6be5ac0c9..5559384236 100644 --- a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt +++ b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt @@ -70,15 +70,21 @@ class ProfileRemovalStore internal constructor(private val store: DataStore = observationRevision.flatMapLatest { - store.data.map { it.attemptId != null } + private val observedState: Flow = observationRevision.flatMapLatest { + store.data.map { it } .retryWhen { _, attempt -> - emit(true) + emit(null) delay((250L shl attempt.coerceAtMost(8).toInt()).coerceAtMost(60_000).milliseconds) true } } + internal val pending: Flow = observedState.map { it == null || it.attemptId != null } + + internal val localDisconnectAvailable: Flow = observedState.map { + it?.attemptId != null && it.scope == ProfileRemovalScope.PROFILE + } + internal suspend fun pendingAttempt(): String? = store.data.first().attemptId internal suspend fun requireNoPending() { @@ -263,7 +269,7 @@ internal object ProfileRemovalSerializer : Serializer { } internal class ProfileRemovalPending(cause: Throwable? = null) : AppError( - "Profile cleanup is pending. Disconnect the profile to finish local recovery.", + "Local recovery is pending. Open the profile for recovery options.", cause, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4f25822652..9f3bf97644 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -20,7 +20,9 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -575,8 +577,16 @@ class LightningRepo @Inject constructor( settledReceiveInvoice = invalidateSettledReceiveInvoice(event) settledReceiveAddress = null val paymentId = event.paymentId ?: event.paymentHash - runSuspendCatching { coreService.activity.handlePaymentEvent(paymentId) } - .onFailure { Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) } + runCatching { coreService.activity.handlePaymentEvent(paymentId) } + .onFailure { + if (it is CancellationException) { + // Receipt admission can be revoked without cancelling the node listener. + currentCoroutineContext().ensureActive() + Logger.debug("Receipt admission ended for '$paymentId'", context = TAG) + } else { + Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) + } + } } is Event.OnchainTransactionReceived -> { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 5ce29fdcbe..4ab5a410e7 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -272,8 +272,9 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( suspend fun clear() = withContext(ioDispatcher) { locked { - ledger = PrivatePaykitReservationData() + ledger = null reservationStore.reset() + ledger = PrivatePaykitReservationData() notifyBackupStateChanged() } } @@ -303,10 +304,15 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( contactAssignments = current.contactAssignments + (assignmentKey to assignment), contactAssignmentHistory = current.contactAssignmentHistory + (assignmentKey to history), ) - ledger = next - persist(next) + // A cancelled return can follow a committed write. Reload durable state on uncertainty. + ledger = null + try { + persist(next) + ledger = next + } finally { + notifyBackupStateChanged() + } } - notifyBackupStateChanged() addressInfo.address } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index 1296eea026..5cfe7dc6b5 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -112,9 +112,13 @@ class PrivatePaykitContactResolver @Inject constructor( suspend fun isReservedReceiveAddress(address: String): Boolean { requireCurrent() - val source = snapshot ?: return false return lookupAssociations { - addressReservationRepo.get().contactsForReceipt(address, source.assignments).isNotEmpty() + val source = snapshot + if (source == null) { + addressReservationRepo.get().isUnavailableForReusableReceive(address) + } else { + addressReservationRepo.get().contactsForReceipt(address, source.assignments).isNotEmpty() + } } ?: false } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index df60438c9b..2e8c5112ed 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -272,6 +272,9 @@ class PubkyRepo @Inject constructor( .map { it } .stateIn(scope, SharingStarted.Eagerly, null) + val profileRecoveryCanDisconnect: StateFlow = profileRemovalStore.localDisconnectAvailable + .stateIn(scope, SharingStarted.Eagerly, false) + suspend fun requiresProfileRecovery(): Boolean = withContext(ioDispatcher) { runSuspendCatching { profileRemovalStore.requireNoPending() }.isFailure } 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 221769a74c..6acbdbe1f0 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 @@ -145,14 +145,24 @@ internal fun ProfileContent( uiState.recoveryPending -> ScreenColumn { topBar(Modifier) VerticalSpacer(32.dp) - BodyM(text = stringResource(R.string.profile__cleanup_pending)) - VerticalSpacer(24.dp) - PrimaryButton( - text = stringResource(R.string.profile__sign_out), - onClick = onClickSignOut, - enabled = !uiState.isSigningOut, - modifier = Modifier.testTag("ProfileRecoveryDisconnect"), + BodyM( + text = stringResource( + if (uiState.recoveryCanDisconnect) { + R.string.profile__cleanup_pending + } else { + R.string.profile__wallet_recovery_pending + }, + ), ) + if (uiState.recoveryCanDisconnect) { + VerticalSpacer(24.dp) + PrimaryButton( + text = stringResource(R.string.profile__sign_out), + onClick = onClickSignOut, + enabled = !uiState.isSigningOut, + modifier = Modifier.testTag("ProfileRecoveryDisconnect"), + ) + } if (uiState.isSigningOut) GradientCircularProgressIndicator(Modifier.size(32.dp)) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index f8465e7044..30d265ae4e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -83,9 +83,13 @@ class ProfileViewModel @Inject constructor( ) } - private val controls = combine(dialogControls, pubkyRepo.profileRecoveryPending, _localDisconnect) { - controls, pending, localOnly -> - controls.copy(recoveryPending = pending, localDisconnect = localOnly) + private val controls = combine( + dialogControls, + pubkyRepo.profileRecoveryPending, + _localDisconnect, + pubkyRepo.profileRecoveryCanDisconnect, + ) { controls, pending, localOnly, canDisconnect -> + controls.copy(recoveryPending = pending, localDisconnect = localOnly, recoveryCanDisconnect = canDisconnect) } init { @@ -108,7 +112,10 @@ class ProfileViewModel @Inject constructor( publicKey = pubkyRepo.publicKey.value, isLoading = pubkyRepo.isLoadingProfile.value || pubkyRepo.isRestoringSession.value || _isRefreshing.value, cachedProfile = pubkyRepo.cachedProfile.value, - controls = ProfileControls(recoveryPending = pubkyRepo.profileRecoveryPending.value), + controls = ProfileControls( + recoveryPending = pubkyRepo.profileRecoveryPending.value, + recoveryCanDisconnect = pubkyRepo.profileRecoveryCanDisconnect.value, + ), ), ) @@ -148,6 +155,7 @@ class ProfileViewModel @Inject constructor( fun showSignOutConfirmation() { if (_isSigningOut.value) return val state = uiState.value + if (state.recoveryPending && !state.recoveryCanDisconnect) return val owner = if (state.recoveryPending) { pubkyRepo.profileOwner().takeIf { it.signIn == null } ?: state.owner } else { @@ -274,6 +282,7 @@ class ProfileViewModel @Inject constructor( publicKey == pubkyRepo.profile.value?.publicKey, isLoading = controls.recoveryPending == null || (controls.recoveryPending == false && isLoading), recoveryPending = controls.recoveryPending == true, + recoveryCanDisconnect = controls.recoveryCanDisconnect, localDisconnect = controls.localDisconnect, owner = displayedOwner, showSignOutDialog = controls.showSignOutDialog, @@ -325,6 +334,7 @@ data class ProfileUiState( val publicKey: String? = null, val canEdit: Boolean = false, val recoveryPending: Boolean = false, + val recoveryCanDisconnect: Boolean = false, val localDisconnect: Boolean = false, val owner: PubkyProfileOwner? = null, val isLoading: Boolean = true, @@ -336,6 +346,7 @@ data class ProfileUiState( private data class ProfileControls( val recoveryPending: Boolean? = null, + val recoveryCanDisconnect: Boolean = false, val localDisconnect: Boolean = false, val showSignOutDialog: Boolean = false, val isSigningOut: Boolean = false, diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ccddeaf39a..94288b9cd1 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -78,9 +78,9 @@ class WipeWalletUseCase @Inject constructor( val pending = profileRemovalStore.pendingAttempt() val attemptId = pending ?: UUID.randomUUID().toString() profileRemovalStore.withAccess(attemptId, ProfileRemovalScope.WALLET_WIPE) { - profileRemovalStore.begin(attemptId) privatePaykitRepo.get().withProfileDeletionPaused { pause -> stopNode().getOrThrow() + profileRemovalStore.begin(attemptId) if (pending == null) cleanupRemote() wipeLocal(walletIndex, pause) { credentialsMayBeErased = true }.getOrThrow() profileRemovalStore.complete(attemptId) @@ -130,9 +130,9 @@ class WipeWalletUseCase @Inject constructor( usdtRepo.wipe { keychain.wipe() }.getOrThrow() paykitUsdtPaymentRepo.resetForWipe() // Consumption watermarks survive until the keychain wipe is acknowledged. Pending remains on any failure. + privatePaykitAddressReservationRepo.clear() privatePaykitRepo.get().finishProfileRemoval(pause).getOrElse { return Result.failure(it) } pubkyRepo.finishProfileWipeLocally() - privatePaykitAddressReservationRepo.clear() step("delete FCM token") { firebaseMessaging.deleteToken() } step("wipe core data") { coreService.wipeData() } step("clear database") { db.clearAllTables() } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ad4ec1fd94..25f36a5483 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1346,6 +1346,7 @@ تعذر إلغاء ربط الـ pubky إلغاء ربط الـ pubky تم إلغاء ربط الـ pubky + يجب إكمال استرداد المحفظة قبل فصل هذا الملف الشخصي. راجع استرداد المحفظة في الإعدادات. اسمك Pubky الخاص بك تم تسجيل الدخول بالفعل diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 9f367761fc..40e2af3b6d 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1363,6 +1363,7 @@ Esto desconectará su perfil de Pubky de Bitkit. Puede volver a conectarlo en cualquier momento. Desconectar perfil Este pubky ya no está disponible en Pubky Ring. Su perfil se desconectó. + Debes completar la recuperación de la billetera antes de desconectar este perfil. Revisa la recuperación de la billetera en Ajustes. Su pubky Ya inició sesión Homeserver diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ce4e50acad..0c92cca295 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1360,6 +1360,7 @@ Desconnectar el perfil Aquest pubky ja no està disponible a Pubky Ring. El teu perfil s\'ha desconnectat. Suggeriments per afegir + Cal completar la recuperació de la cartera abans de desconnectar aquest perfil. Consulta la recuperació de la cartera a Configuració. El teu pubky Ja has iniciat sessió Homeserver diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index eee8f6760e..ae9806b4a6 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1362,6 +1362,7 @@ Odpojit profil Tento pubky již není v Pubky Ring k dispozici. Váš profil byl odpojen. Návrhy k přidání + Před odpojením tohoto profilu je nutné dokončit obnovu peněženky. Zkontrolujte obnovu peněženky v Nastavení. Váš pubky Již přihlášeno Homeserver diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index da2ec2662b..8e07554337 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1360,6 +1360,7 @@ Profil trennen Dieser pubky ist in Pubky Ring nicht mehr verfügbar. Dein Profil wurde getrennt. Vorschläge zum Hinzufügen + Bevor dieses Profil getrennt werden kann, muss die Wallet-Wiederherstellung abgeschlossen werden. Prüfe die Wallet-Wiederherstellung in den Einstellungen. Dein Pubky Bereits angemeldet Homeserver diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 8ebbdf1936..e8711e517e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1352,6 +1352,7 @@ Αδυναμία αποσύνδεσης pubky Αποσύνδεση pubky Το pubky αποσυνδέθηκε + Πρέπει να ολοκληρωθεί η ανάκτηση του πορτοφολιού πριν αποσυνδεθεί αυτό το προφίλ. Ελέγξτε την ανάκτηση πορτοφολιού στις Ρυθμίσεις. Το όνομά σου Το pubky σου Είσαι ήδη συνδεδεμένος diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 5cff213254..e19dbf5288 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1357,6 +1357,7 @@ Esto desconectará tu perfil de Pubky de Bitkit. Puedes volver a conectarlo en cualquier momento. Desconectar perfil Este pubky ya no está disponible en Pubky Ring. Tu perfil se ha desconectado. + Debes completar la recuperación de la cartera antes de desconectar este perfil. Revisa la recuperación de la cartera en Ajustes. Tu pubky Ya has iniciado sesión Servidor principal diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8ded52f6a1..24337bc3e9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1356,6 +1356,7 @@ Esto desconectará su perfil de Pubky de Bitkit. Puede volver a conectarlo en cualquier momento. Desconectar perfil Este pubky ya no está disponible en Pubky Ring. Su perfil se ha desconectado. + Debes completar la recuperación de la cartera antes de desconectar este perfil. Revisa la recuperación de la cartera en Ajustes. Su pubky Ya ha iniciado sesión Servidor principal diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7c049eb669..85594b0ed5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1358,6 +1358,7 @@ Déconnecter le profil Ce pubky n\'est plus disponible dans Pubky Ring. Votre profil a été déconnecté. Suggestions + La récupération du portefeuille doit être terminée avant de déconnecter ce profil. Consultez la récupération du portefeuille dans les Paramètres. Votre pubky Déjà connecté Serveur domestique diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 939c892ca8..4c0d1b0feb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1362,6 +1362,7 @@ Questo disconnetterà il tuo profilo Pubky da Bitkit. Puoi ricollegarlo in qualsiasi momento. Disconnetti Profilo Questo pubky non è più disponibile in Pubky Ring. Il tuo profilo è stato disconnesso. + Devi completare il recupero del portafoglio prima di disconnettere questo profilo. Controlla il recupero del portafoglio nelle Impostazioni. Il Tuo Pubky Accesso già effettuato Homeserver diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 531f52463d..34bb1135f6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1359,6 +1359,7 @@ Hiermee wordt uw Pubky-profiel ontkoppeld van Bitkit. U kunt op elk moment opnieuw verbinden. Profiel ontkoppelen Deze pubky is niet meer beschikbaar in Pubky Ring. Uw profiel is ontkoppeld. + Het wallet-herstel moet worden voltooid voordat dit profiel kan worden losgekoppeld. Controleer het wallet-herstel in Instellingen. Uw pubky Al ingelogd Homeserver diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 2ecd17de7c..c70056576e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1360,6 +1360,7 @@ Spowoduje to odłączenie Twojego profilu Pubky od Bitkit. Możesz połączyć się ponownie w dowolnym momencie. Odłącz profil Ten pubky nie jest już dostępny w Pubky Ring. Twój profil został odłączony. + Przed odłączeniem tego profilu należy ukończyć odzyskiwanie portfela. Sprawdź odzyskiwanie portfela w Ustawieniach. Twój pubky Już zalogowano Serwer domowy diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 033ba77f40..454adcf5be 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1362,6 +1362,7 @@ Isso desconectará seu perfil Pubky da Bitkit. Você pode reconectar a qualquer momento. Desconectar Perfil Esta pubky não está mais disponível no Pubky Ring. Seu perfil foi desconectado. + É necessário concluir a recuperação da carteira antes de desconectar este perfil. Confira a recuperação da carteira nas Configurações. Sua Pubky Já conectado Homeserver diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4862c52a57..6ffa9bb206 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1345,6 +1345,7 @@ Não foi possível desassociar o pubky Desassociar pubky Pubky desassociado + É necessário concluir a recuperação da carteira antes de desligar este perfil. Consulte a recuperação da carteira nas Definições. O seu nome O seu pubky Sessão já iniciada diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c1b2d97454..838c3fd7dd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1377,6 +1377,7 @@ Ваш профиль Pubky будет отключён от Bitkit. Вы можете переподключиться в любое время. Отключить Профиль Этот pubky больше не доступен в Pubky Ring. Ваш профиль отключён. + Перед отключением этого профиля необходимо завершить восстановление кошелька. Проверьте восстановление кошелька в Настройках. Ваш Pubky Вход уже выполнен Домашний сервер diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0011b8f913..c158c2e516 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -682,6 +682,7 @@ Unable to Unlink Pubky Unlink pubky Pubky unlinked + Wallet recovery is required before this profile can be disconnected. Check wallet recovery in Settings. Your Name Your Pubky Already signed in diff --git a/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt b/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt index f2c5b307e4..b38f32c97b 100644 --- a/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/ProfileRemovalStoreTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -44,9 +45,11 @@ class ProfileRemovalStoreTest { val id = UUID.randomUUID().toString() store.withAccess(id) { store.begin(id) + assertTrue(store.localDisconnectAvailable.first()) assertEquals("historical receipt", store.withReceiptSnapshot { "historical receipt" }) } store.withAccess(id, ProfileRemovalScope.WALLET_WIPE) { store.begin(id) } + assertFalse(store.localDisconnectAvailable.first()) assertFailsWith { store.withReceiptSnapshot { error("must not copy") } } assertFailsWith { store.withAccess(id) { store.complete(id) } } assertEquals(id, store.pendingAttempt()) @@ -129,7 +132,9 @@ class ProfileRemovalStoreTest { transform(state.value).also { state.value = it } }) assertFailsWith { store.withReceiptSnapshot { error("legacy state") } } + assertFalse(store.localDisconnectAvailable.first()) unavailable = true + assertFalse(store.localDisconnectAvailable.first()) assertTrue( assertFailsWith { store.withReceiptSnapshot { error("unavailable state") } @@ -275,6 +280,8 @@ class ProfileRemovalStoreTest { ) { file } ) assertEquals(recovered, second.pendingAttempt()) + assertFalse(second.localDisconnectAvailable.first()) + assertFailsWith { second.requireReceiptAccountingAccess() } assertFailsWith { second.withAccess(recovered) { second.complete(recovered) } } second.withAccess(recovered, ProfileRemovalScope.WALLET_WIPE) { second.begin(recovered) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 9e6b882d37..bd6322ff1f 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -410,6 +410,47 @@ class LightningRepoTest : BaseUnitTest() { ) } + @Test + fun `receipt admission cancellation preserves the active listener event dispatch`() = test { + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(activityService.handlePaymentEvent("payment-id")).doSuspendableAnswer { + throw CancellationException("Receipt admission refused") + } + val eventHandler = startNodeAndCaptureEvents() + val publishedUpdate = async { sut.nodeEventUpdates.first() } + val event = Event.PaymentReceived("payment-id", "010203", 1_000uL, emptyList()) + + val outcome = runCatching { eventHandler(event) } + + try { + assertTrue(outcome.isSuccess) + assertEquals(event, publishedUpdate.await().event) + } finally { + publishedUpdate.cancel() + } + } + + @Test + fun `cancelling the listener still propagates through received payment admission`() = test { + val entered = CompletableDeferred() + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(activityService.handlePaymentEvent("payment-id")).doSuspendableAnswer { + entered.complete(Unit) + awaitCancellation() + } + val eventHandler = startNodeAndCaptureEvents() + var returned = false + val handling = launch { + eventHandler(Event.PaymentReceived("payment-id", "010203", 1_000uL, emptyList())) + returned = true + } + entered.await() + handling.cancelAndJoin() + assertFalse(returned) + } + @Test fun `PaymentReceived preserves a cached invoice with a different payment hash`() = test { val bolt11 = "lnbc1" diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index c484fbf6a3..934f187205 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AddressType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -25,6 +26,7 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest +import java.io.IOException import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -68,6 +70,39 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ) } + @Test + fun `failed reservation persistence cannot expose the unacknowledged address on retry`() = test { + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn( + Result.success(AddressDerivationInfo(address = PRIVATE_ADDRESS, index = 1)), + ) + whenever(reservationStore.update(any())).doSuspendableAnswer { throw IOException("Reservation write refused") } + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) + whenever(lightningRepo.getAddressBalance(PRIVATE_ADDRESS)).thenReturn(Result.success(0u)) + + assertTrue(sut.currentOrRotatedAddress(CONTACT_KEY).exceptionOrNull() is IOException) + assertTrue(sut.currentOrRotatedAddress(CONTACT_KEY).isFailure) + assertTrue(reservationData.value.contactAssignments.isEmpty()) + } + + @Test + fun `lost reservation write acknowledgement reloads committed state without allocating again`() = test { + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn( + Result.success(AddressDerivationInfo(address = PRIVATE_ADDRESS, index = 1)), + ) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) + whenever(lightningRepo.getAddressBalance(PRIVATE_ADDRESS)).thenReturn(Result.success(0u)) + whenever(reservationStore.update(any())).doSuspendableAnswer { + val transform = it.getArgument<(PrivatePaykitReservationData) -> PrivatePaykitReservationData>(0) + reservationData.value = transform(reservationData.value) + throw CancellationException("Committed write lost its acknowledgement") + } + assertFailsWith { sut.currentOrRotatedAddress(CONTACT_KEY) } + assertEquals(PRIVATE_ADDRESS, sut.currentOrRotatedAddress(CONTACT_KEY).getOrThrow()) + assertEquals(mapOf("nativeSegwit" to 1), sut.backupSnapshot().getOrThrow()) + assertTrue(sut.backupStateVersion.value > 0) + verify(lightningRepo).newAddressInfoForType(AddressType.P2WPKH) + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `contact cleanup rechecks snapshot after ledger load and lock handoff`() = test { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index 637682c5a0..3dc1631e5e 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -139,9 +139,11 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val storageScope = CoroutineScope(SupervisorJob() + testDispatcher) val storage = DataStoreFactory.create(PrivatePaykitCacheSerializer, scope = storageScope) { file } whenever(cacheStore.data).thenReturn(storage.data) + whenever(addressReservationRepo.isUnavailableForReusableReceive(PRIVATE_ADDRESS)).thenReturn(true) sut.withReceiptOperation { assertNull(it.snapshot) assertNull(it.contactForInvoice(PAYMENT_HASH)) + assertTrue(it.isReservedReceiveAddress(PRIVATE_ADDRESS)) } assertFailsWith { storage.updateData { it.copy(cleanupPending = true) } } requireNotNull(storageScope.coroutineContext[Job]).cancelAndJoin() diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 40d7e4e521..076cb9a173 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -490,6 +490,34 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertEquals(2, activityReads) } + @Test + fun `unavailable invoice attribution still records a high reserved receiving address`() = coreTest { + val address = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" + val store = mock() + whenever(store.data).thenReturn( + flowOf( + PrivatePaykitReservationData(reservedReceiveIndexesByAddressType = mapOf("nativeSegwit" to setOf(9000))) + ), + ) + val derivation = mock() + whenever(derivation.addressInfoForType(addressType = AddressType.P2WPKH, receiveIndex = 9000)).thenReturn( + Result.success(AddressDerivationInfo(address, 9000)), + ) + receiptReservations = PrivatePaykitAddressReservationRepo( + testDispatcher, store, settingsStore, coreService, derivation, + ) + whenever(privateCacheStore.data).thenReturn(flowOf(PrivatePaykitCacheData(receiptSourceReadable = false))) + whenever(lightningService.addressInfosForType(any(), any(), any(), any(), any())).thenReturn(emptyList()) + + receive(address) + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals(address, row.address) + assertEquals(15_000uL, row.value) + assertNull(row.contact) + assertFalse(sut.backfillPaykitContacts()) + } + @Test fun `held incoming search uses its captured high index reservation after profile cleanup`() = coreTest { val address = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index e90fbd80a9..939eeef1ab 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -408,6 +408,26 @@ class ProfileViewModelTest : BaseUnitTest() { } } + @Test + fun `unknown wallet recovery cannot prepare profile disconnect`() = test { + val sut = createSut( + publicKey = null, + recoveryPending = MutableStateFlow(true), + recoveryCanDisconnect = MutableStateFlow(false), + ) + sut.uiState.test { + advanceUntilIdle() + assertTrue(sut.uiState.value.recoveryPending) + assertFalse(sut.uiState.value.recoveryCanDisconnect) + sut.showSignOutConfirmation() + advanceUntilIdle() + assertFalse(sut.uiState.value.showSignOutDialog) + assertFalse(sut.uiState.value.isSigningOut) + cancelAndIgnoreRemainingEvents() + } + verify(pubkyRepo, never()).signOutForProfileRemoval() + } + @Test fun `pending restart without credentials shows local recovery without authentication`() = test { val pending = MutableStateFlow(null) @@ -624,6 +644,7 @@ class ProfileViewModelTest : BaseUnitTest() { private fun createSut( profile: PubkyProfile? = null, recoveryPending: MutableStateFlow = MutableStateFlow(false), + recoveryCanDisconnect: MutableStateFlow = MutableStateFlow(true), profileFlow: MutableStateFlow = MutableStateFlow(profile), readOnlyProfileFlow: MutableStateFlow = MutableStateFlow(null), publicKey: String? = "pubkyalice", @@ -638,6 +659,7 @@ class ProfileViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") fixture.setup() whenever(pubkyRepo.profileRecoveryPending).thenReturn(recoveryPending) + whenever(pubkyRepo.profileRecoveryCanDisconnect).thenReturn(recoveryCanDisconnect) whenever(pubkyRepo.profile).thenReturn(profileFlow) whenever(pubkyRepo.readOnlyProfile).thenReturn(readOnlyProfileFlow) whenever(pubkyRepo.publicKey).thenReturn(publicKeyFlow) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index ba6965f388..402e6107b5 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -1,6 +1,8 @@ package to.bitkit.usecases +import androidx.datastore.core.DataStoreFactory import com.google.firebase.messaging.FirebaseMessaging +import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -8,6 +10,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -15,7 +18,9 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder @@ -26,6 +31,9 @@ import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue import to.bitkit.data.AppDb import to.bitkit.data.CacheStore +import to.bitkit.data.PrivatePaykitReservationData +import to.bitkit.data.PrivatePaykitReservationSerializer +import to.bitkit.data.PrivatePaykitReservationStore import to.bitkit.data.ProfileRemovalStore import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyStore @@ -49,6 +57,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WatchOnlyAccountRepo +import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.PaykitSdkService @@ -69,6 +78,8 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class WipeWalletUseCaseTest : BaseUnitTest() { + @get:Rule + val folder = TemporaryFolder() private val backupRepo = mock() private val keychain = mock() @@ -140,6 +151,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private fun createSut( pubkyRepo: PubkyRepo = this.pubkyRepo, sharingRepo: ContactPaymentSettingsRepo = contactPaymentSettingsRepo, + reservations: PrivatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, ) = WipeWalletUseCase( backupRepo = backupRepo, keychain = keychain, @@ -157,7 +169,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { lightningRepo = lightningRepo, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepoProvider, - privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, + privatePaykitAddressReservationRepo = reservations, firebaseMessaging = firebaseMessaging, migrationService = migrationService, paykitSdkService = paykitSdkService, @@ -310,6 +322,46 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(keychain).wipe() } + @Test + fun `wipe replaces unreadable reservations after key acknowledgement before profile cleanup`() = test { + val file = folder.newFile("reservations").also { it.writeText("{broken") } + val storage = DataStoreFactory.create(PrivatePaykitReservationSerializer, scope = backgroundScope) { file } + val store = mock() + whenever(store.data).thenReturn(storage.data) + whenever(store.update(any())).doSuspendableAnswer { + val transform = it.getArgument<(PrivatePaykitReservationData) -> PrivatePaykitReservationData>(0) + storage.updateData { current -> transform(current) } + Unit + } + whenever(store.reset()).doSuspendableAnswer { + verify(keychain).wipe() + storage.updateData { PrivatePaykitReservationData() } + Unit + } + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + val reservations = PrivatePaykitAddressReservationRepo( + testDispatcher, + store, + settingsStore, + coreService, + lightningRepo, + ) + val contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn( + Result.success(AddressDerivationInfo("bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq", 1)), + ) + assertTrue(reservations.currentOrRotatedAddress(contact).isFailure) + assertFalse(storage.data.first().receiptSourceReadable) + whenever(privatePaykitRepo.finishProfileRemoval(any())).doSuspendableAnswer { + assertTrue(storage.data.first().receiptSourceReadable) + reservations.clearContactAssignments(emptySet()) + Result.success(Unit) + } + assertTrue(createSut(reservations = reservations).invoke(resetWalletState = {}, onSuccess = {}).isSuccess) + assertEquals(PrivatePaykitReservationData(), storage.data.first()) + assertNull(profileRemovalStore.pendingAttempt()) + } + @Test fun `pending restart wipe skips remote cleanup and only clears marker after local acknowledgement`() = test { val id = UUID.randomUUID().toString() @@ -358,7 +410,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { assertEquals(failure.message, assertIs(result.exceptionOrNull()).cause?.message) verify(keychain).wipe() verify(privatePaykitRepo).finishProfileRemoval(profilePause) - verify(privatePaykitAddressReservationRepo, never()).clear() + verify(privatePaykitAddressReservationRepo).clear() assertNotNull(profileRemovalStore.pendingAttempt()) assertFalse(onWipeCalled) assertFalse(onSetWalletExistsStateCalled) @@ -423,9 +475,9 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(usdtRepo).wipe(any()) inOrder.verify(keychain).wipe() inOrder.verify(paykitUsdtPaymentRepo).resetForWipe() + inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(privatePaykitRepo).finishProfileRemoval(profilePause) inOrder.verify(pubkyRepo).finishProfileWipeLocally() - inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(coreService).wipeData() inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() @@ -480,10 +532,36 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(lightningRepo, never()).wipeStorage(any()) verify(keychain, never()).wipe() verify(db, never()).clearAllTables() + assertNull(profileRemovalStore.pendingAttempt()) assertFalse(onWipeCalled) assertFalse(onSetWalletExistsStateCalled) } + @Test + fun `cancellation while stopping preserves prior recovery scope without arming a fresh wipe`() = test { + for (pending in listOf(null, UUID.randomUUID().toString())) { + if (pending != null) profileRemovalStore.withAccess(pending) { profileRemovalStore.begin(pending) } + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.stop()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Result.failure(CancellationException("Node stop cancelled")) + } + val wiping = launch { sut.invoke(resetWalletState = {}, onSuccess = {}) } + entered.await() + wiping.cancel() + release.complete(Unit) + wiping.join() + assertEquals(pending, profileRemovalStore.pendingAttempt()) + // A failed pre-stop wipe must not upgrade an existing PROFILE obligation. + if (pending != null) profileRemovalStore.withAccess(pending) { profileRemovalStore.complete(pending) } + connectionRecovery.withReceiptOperation { it.requireCurrent() } + } + verify(keychain, never()).wipe() + verify(lightningRepo, never()).wipeStorage(any()) + } + @Test fun `invoke retains pending recovery when address reservation clear fails`() = runTest { whenever { privatePaykitAddressReservationRepo.clear() }.thenThrow(RuntimeException("clear failed")) @@ -517,6 +595,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(settingsStore, never()).reset() assertFalse(onWipeCalled) verify(privatePaykitRepo, never()).finishProfileRemoval(any()) + verify(privatePaykitAddressReservationRepo, never()).clear() assertNotNull(profileRemovalStore.pendingAttempt()) assertFalse(onSetWalletExistsStateCalled) } diff --git a/changelog.d/next/1693.fixed.md b/changelog.d/next/1693.fixed.md index 8648293a8c..3c8ab81c1c 100644 --- a/changelog.d/next/1693.fixed.md +++ b/changelog.d/next/1693.fixed.md @@ -1 +1 @@ -Keep interrupted profile removal recoverable without changing a replacement identity or losing contact details already captured for incoming payments. +Keep interrupted profile removal recoverable without changing replacement identities or losing incoming payment details, and show recovery options that match the pending wallet state.