diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index e2209791..228e10d7 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -6,6 +6,14 @@ follow [https://changelog.md/](https://changelog.md/) guidelines. ## [Unreleased] +## [52] - 2024-06-14 + +### ADDED + +- Wallet delete + All users can now request wallet delete, to comply with Google Play Account Deletion Data Safety + policy. + ## [51.11] - 2024-05-18 ### FIXED diff --git a/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt b/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt index f8d3657f..db7513b9 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt @@ -105,6 +105,11 @@ abstract class Globals { */ abstract val rcLoginAuthorizePath: String + /** + * Get the path of this app's "Confirm Account Deletion" deeplink. + */ + abstract val confirmAccountDeletionPath: String + /** * Get Lapp's URL. */ diff --git a/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java b/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java index ee8ea71c..b1b88f2f 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java @@ -7,6 +7,8 @@ import io.muun.apollo.data.os.GooglePlayServicesHelper; import io.muun.apollo.data.os.HardwareCapabilitiesProvider; import io.muun.apollo.data.os.OS_ExtensionsKt; +import io.muun.apollo.domain.errors.delete_wallet.NonEmptyWalletDeleteException; +import io.muun.apollo.domain.errors.delete_wallet.UnsettledOperationsWalletDeleteException; import io.muun.apollo.domain.errors.newop.CyclicalSwapError; import io.muun.apollo.domain.errors.newop.InvalidInvoiceException; import io.muun.apollo.domain.errors.newop.InvoiceAlreadyUsedException; @@ -42,6 +44,7 @@ import io.muun.apollo.domain.model.user.UserProfile; import io.muun.common.Optional; import io.muun.common.api.ChallengeSetupVerifyJson; +import io.muun.common.api.ChallengeSignatureJson; import io.muun.common.api.CreateFirstSessionJson; import io.muun.common.api.CreateLoginSessionJson; import io.muun.common.api.CreateRcLoginSessionJson; @@ -72,6 +75,7 @@ import io.muun.common.model.challenge.Challenge; import io.muun.common.model.challenge.ChallengeSetup; import io.muun.common.model.challenge.ChallengeSignature; +import io.muun.common.rx.CompletableFn; import io.muun.common.rx.ObservableFn; import io.muun.common.rx.RxHelper; import io.muun.common.utils.Encodings; @@ -80,6 +84,7 @@ import android.content.Context; import android.net.Uri; +import androidx.annotation.NonNull; import libwallet.MusigNonces; import okhttp3.MediaType; import rx.Completable; @@ -346,7 +351,7 @@ public Observable loginCompatWithoutChallenge() { /** * Notify houston of a client logout. Not a critical request, in fact its just so Houston - * can now IN ADVANCE of a session expiration (otherwise will have to wait until a new create + * can know IN ADVANCE of a session expiration (otherwise will have to wait until a new create * session to invalidate old ones). So, its a fire and forget call. */ public Observable notifyLogout(String authHeader) { @@ -786,4 +791,29 @@ incomingSwap, new PreimageJson(Encodings.bytesToHex(preimage)) public Completable updateUserPreferences(final UserPreferences prefs) { return getService().updateUserPreferences(prefs.toJson()); } + + /** + * Irreversibly delete current user wallet. + */ + public Completable deleteWallet(@NonNull final ChallengeSignature challengeSignature) { + final ChallengeSignatureJson challengeSignatureJson = + apiMapper.mapChallengeSignature(challengeSignature); + + return getService().deleteWallet(challengeSignatureJson) + .compose(CompletableFn.replaceHttpException( + ErrorCode.WALLET_NOT_EMPTY, + NonEmptyWalletDeleteException::new + )) + .compose(CompletableFn.replaceHttpException( + ErrorCode.UNSETTLED_OPERATIONS, + UnsettledOperationsWalletDeleteException::new + )); + } + + /** + * Use an external confirmation link (received by email) to confirm account deletion. + */ + public Observable confirmAccountDeletion(String uuid) { + return getService().confirmAccountDeletion(new LinkActionJson(uuid)); + } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java index edfae081..156258a1 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java @@ -34,6 +34,7 @@ import io.muun.apollo.domain.action.session.SyncApplicationDataAction; import io.muun.apollo.domain.action.session.UseMuunLinkAction; import io.muun.apollo.domain.action.session.rc_only.LogInWithRcAction; +import io.muun.apollo.domain.action.user.DeleteWalletAction; import io.muun.apollo.domain.action.user.EmailLinkAction; import io.muun.apollo.domain.action.user.UpdateProfilePictureAction; import io.muun.apollo.domain.debug.DebugExecutable; @@ -129,5 +130,7 @@ public interface ActionComponent { UpdateContactsPermissionStateAction updateContactsPermissionStateAction(); + DeleteWalletAction deleteWalletAction(); + DebugExecutable debugExecutable(); } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/UseMuunLinkAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/UseMuunLinkAction.kt index f9a40653..fb582be8 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/UseMuunLinkAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/UseMuunLinkAction.kt @@ -43,6 +43,7 @@ open class UseMuunLinkAction @Inject constructor( isEmailAuthorize(parser) -> houstonClient.useAuthorizeLink(uuid) isEmailConfirm(parser) -> houstonClient.useConfirmLink(uuid) isRcLoginAuthorize(parser) -> houstonClient.authorizeLoginWithRecoveryCode(uuid) + isAccountDeletionConfirm(parser) -> houstonClient.confirmAccountDeletion(uuid) else -> throw MissingCaseError(linkUri, "Muun links") @@ -63,4 +64,7 @@ open class UseMuunLinkAction @Inject constructor( private fun isRcLoginAuthorize(p: UriParser) = p.pathWithSlash == Globals.INSTANCE.rcLoginAuthorizePath + + private fun isAccountDeletionConfirm(p: UriParser) = + p.pathWithSlash == Globals.INSTANCE.confirmAccountDeletionPath } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/user/DeleteWalletAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/user/DeleteWalletAction.kt new file mode 100644 index 00000000..b9df992c --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/user/DeleteWalletAction.kt @@ -0,0 +1,32 @@ +package io.muun.apollo.domain.action.user + +import io.muun.apollo.data.net.HoustonClient +import io.muun.apollo.domain.action.base.BaseAsyncAction0 +import io.muun.apollo.domain.action.challenge_keys.SignChallengeAction +import io.muun.common.Optional +import io.muun.common.crypto.ChallengeType +import io.muun.common.model.challenge.Challenge +import rx.Observable +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeleteWalletAction @Inject constructor( + private val signChallenge: SignChallengeAction, + private val houstonClient: HoustonClient, +) : BaseAsyncAction0() { + + override fun action(): Observable { + return Observable.defer { + houstonClient.requestChallenge(ChallengeType.USER_KEY) + .map { maybeChallenge: Optional -> + val challenge = maybeChallenge.orElseThrow() // empty only for legacy apps + signChallenge.signWithUserKey(challenge) + } + .flatMap { challengeSignature -> + houstonClient.deleteWallet(challengeSignature) + .andThen(Observable.just(null)) + } + } + } +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt b/android/apollo/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt index fb6b34aa..162e20dc 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt @@ -296,7 +296,16 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( class E_RECOVERY_CODE_SET_UP : AnalyticsEvent() class E_LOG_OUT : AnalyticsEvent() class E_WALLET_CREATED : AnalyticsEvent() - class E_WALLET_DELETED : AnalyticsEvent() + class E_WALLET_DELETE(val state: WalletDeleteState) : AnalyticsEvent( + listOf("type" to state.name.lowercase(Locale.getDefault())) + ) + + enum class WalletDeleteState { + STARTED, + ERROR, + SUCCESS, + } + class E_SIGN_IN_ABORTED : AnalyticsEvent() class E_SIGN_IN_SUCCESSFUL(val type: LoginType) : AnalyticsEvent( @@ -441,8 +450,7 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( RC_SETUP_START_CONNECTION_ERROR, RC_SETUP_FINISH_CONNECTION_ERROR, RC_STALE_ERROR, - RC_CREDENTIALS_DONT_MATCH_ERROR, - CRASHLYTICS + RC_CREDENTIALS_DONT_MATCH_ERROR } class E_ERROR(val type: ERROR_TYPE, vararg extras: Any) : AnalyticsEvent( diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt new file mode 100644 index 00000000..482e1a46 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt @@ -0,0 +1,5 @@ +package io.muun.apollo.domain.errors.delete_wallet + +import io.muun.apollo.domain.errors.MuunError + +class NonEmptyWalletDeleteException(cause: Throwable) : MuunError(cause) diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt new file mode 100644 index 00000000..a5e1a633 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt @@ -0,0 +1,5 @@ +package io.muun.apollo.domain.errors.delete_wallet + +import io.muun.apollo.domain.errors.MuunError + +class UnsettledOperationsWalletDeleteException(cause: Throwable) : MuunError(cause) \ No newline at end of file diff --git a/android/apolloui/build.gradle b/android/apolloui/build.gradle index 624bd510..727f11dc 100644 --- a/android/apolloui/build.gradle +++ b/android/apolloui/build.gradle @@ -63,6 +63,7 @@ static def configExternalLinks(productFlavor, String host) { String authorizePath = "/link/authorize/index.html" String changePasswdPath = "/link/confirm/index.html" String rcLoginAuthPath = "/link/authorize-rc/index.html" + String confirmAccountDeletionPath = "/link/confirm-account-deletion/index.html" // Required for AndroidManifest: productFlavor.resValue "string", "muun_link_host", host @@ -70,6 +71,7 @@ static def configExternalLinks(productFlavor, String host) { productFlavor.resValue "string", "authorize_link_path", authorizePath productFlavor.resValue "string", "confirm_link_path", changePasswdPath productFlavor.resValue "string", "rc_login_authorize_link_path", rcLoginAuthPath + productFlavor.resValue "string", "confirm_account_deletion_path", confirmAccountDeletionPath // Required for code access in action layer: productFlavor.buildConfigField "String", "MUUN_LINK_HOST", quote(host) @@ -77,6 +79,7 @@ static def configExternalLinks(productFlavor, String host) { productFlavor.buildConfigField "String", "AUTHORIZE_LINK_PATH", quote(authorizePath) productFlavor.buildConfigField "String", "CONFIRM_LINK_PATH", quote(changePasswdPath) productFlavor.buildConfigField "String", "RC_LOGIN_AUTHORIZE_LINK_PATH", quote(rcLoginAuthPath) + productFlavor.buildConfigField "String", "CONFIRM_ACCOUNT_DELETION_PATH", quote(confirmAccountDeletionPath) } @@ -91,8 +94,8 @@ android { applicationId "io.muun.apollo" minSdkVersion 19 targetSdkVersion 33 - versionCode 1111 - versionName "51.11" + versionCode 1200 + versionName "52" // Needed to make sure these classes are available in the main DEX file for API 19 // See: https://spin.atomicobject.com/2018/07/16/support-kitkat-multidex/ diff --git a/android/apolloui/src/main/AndroidManifest.xml b/android/apolloui/src/main/AndroidManifest.xml index 9b86b559..7f49db0a 100644 --- a/android/apolloui/src/main/AndroidManifest.xml +++ b/android/apolloui/src/main/AndroidManifest.xml @@ -126,6 +126,16 @@ android:path="@string/rc_login_authorize_link_path" /> + + + + + + + + diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.kt index 37e4ba81..80328d32 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.kt @@ -62,6 +62,9 @@ class GlobalsImpl : Globals() { override val rcLoginAuthorizePath: String get() = BuildConfig.RC_LOGIN_AUTHORIZE_LINK_PATH + override val confirmAccountDeletionPath: String + get() = BuildConfig.CONFIRM_ACCOUNT_DELETION_PATH + override val lappUrl: String get() = BuildConfig.LAPP_URL } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt index 815965e8..ce9a3e39 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt @@ -31,8 +31,12 @@ class AlertDialogExtension @Inject constructor() : ActivityExtension() { dismissDialog() // ON dialog dismiss, dispose android's dialog reference to avoid memory leaks - dialog.addOnDismissAction { - activeDialog = null + dialog.addOnDismissAction { dismissedDialog -> + // If activeDialog has changed, it means dismissedDialog is already dismissed and a new + // dialog is being shown. We don't want to lose that ref (to properly dismiss it later). + if (activeDialog == dismissedDialog) { + activeDialog = null + } } activeDialog = dialog.show(activity) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveFragment.kt index 1d6bdda6..d9f599f3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveFragment.kt @@ -70,7 +70,9 @@ class EmergencyKitSaveFragment : SingleFragment(), /** User's choice of application the last time the share dialog was displayed. */ private var chosenShareTarget: String? = null - /** Whether the loading dialog is currently on screen */ + /** Whether the loading dialog is currently on screen. Required due to limitations of + * AlertDialogExtensions. It can't handle multiple dismissDialogs() calls prompted by + * handleStates(). */ private var showingLoadingDialog = false override fun inject() = diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt index 30b0f13e..cc82daa4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt @@ -7,6 +7,7 @@ import android.view.Menu import android.view.MenuInflater import android.view.View import android.widget.TextView +import androidx.core.view.isVisible import butterknife.BindView import io.muun.apollo.BuildConfig import io.muun.apollo.R @@ -78,18 +79,23 @@ open class SettingsFragment : SingleFragment(), SettingsView @BindView(R.id.settings_lightning) lateinit var lightningSettingsItem: MuunSettingItem - @BindView(R.id.settings_logout) - lateinit var logoutItem: View - @BindView(R.id.recovery_section) lateinit var recoverySection: View - @BindView(R.id.log_out_text_view) - lateinit var logOutTextView: TextView + @BindView(R.id.settings_logout) + lateinit var logoutItem: View + + @BindView(R.id.settings_delete_wallet) + lateinit var deleteWalletItem: View @BindView(R.id.settings_version_code) lateinit var versionCode: TextView + /** Whether the loading dialog is currently on screen. Required due to limitations of + * AlertDialogExtensions. It can't handle multiple dismissDialogs() calls prompted by + * handleStates(). */ + private var showingLoadingDialog = false + override fun inject() { component.inject(this) } @@ -109,7 +115,8 @@ open class SettingsFragment : SingleFragment(), SettingsView passwordItem.setOnClickListener { editPassword() } darkModeItem.setOnClickListener { editDarkMode() } bitcoinUnitItem.setOnClickListener { editBitcoinUnit() } - logoutItem.setOnClickListener { goToLogout() } + logoutItem.setOnClickListener { logout() } + deleteWalletItem.setOnClickListener { deleteWallet() } bitcoinSettingsItem.setOnClickListener { goToBitcoinSettings() } lightningSettingsItem.setOnClickListener { goToLightningSettings() } @@ -225,7 +232,7 @@ open class SettingsFragment : SingleFragment(), SettingsView private fun setUpUnrecoverableUser() { recoverySection.visibility = View.GONE - logOutTextView.setText(R.string.settings_delete_wallet) + logoutItem.visibility = View.GONE } private fun setUpRecoverableUser(user: User) { @@ -234,7 +241,8 @@ open class SettingsFragment : SingleFragment(), SettingsView } else { recoverySection.visibility = View.GONE } - logOutTextView.setText(R.string.settings_logout) + logoutItem.visibility = View.VISIBLE + deleteWalletItem.visibility = View.VISIBLE } override fun profilePictureUpdated(userProfile: UserProfile?) { @@ -246,8 +254,25 @@ open class SettingsFragment : SingleFragment(), SettingsView } override fun setLoading(loading: Boolean) { - muunPictureInput.toggleLoading(loading) - muunPictureInput.resetPicture() + if (muunPictureInput.isVisible) { + muunPictureInput.toggleLoading(loading) + muunPictureInput.resetPicture() + } + + if (loading) { + MuunDialog.Builder() + .layout(R.layout.dialog_loading) + .message(R.string.loading) + .setCancelOnTouchOutside(false) + .build() + .let(this::showDialog) + + showingLoadingDialog = true + + } else if (showingLoadingDialog) { + dismissDialog() + showingLoadingDialog = false + } } private fun editUsername() { @@ -286,9 +311,16 @@ open class SettingsFragment : SingleFragment(), SettingsView } /** - * Called when the user taps on the log out or delete wallet button. + * Called when the user taps on the log out button. */ - private fun goToLogout() { + private fun logout() { + presenter.handleLogoutRequest() + } + + /** + * Called when the user taps on the delete wallet button. + */ + private fun deleteWallet() { presenter.handleDeleteWalletRequest() } @@ -298,10 +330,7 @@ open class SettingsFragment : SingleFragment(), SettingsView private fun showBitcoinSettings(state: SettingsState): Boolean = when (state.taprootFeatureStatus) { - PREACTIVATED, - SCHEDULED_ACTIVATION, - ACTIVE, - -> true + PREACTIVATED, SCHEDULED_ACTIVATION, ACTIVE -> true else -> false } @@ -339,28 +368,39 @@ open class SettingsFragment : SingleFragment(), SettingsView showDialog(muunDialog) } - override fun handleDeleteWallet(displayExplanation: Boolean) { - if (displayExplanation) { - val muunDialog = MuunDialog.Builder() - .layout(R.layout.dialog_custom_layout2) - .title(R.string.settings_delete_wallet_explanation_title) - .message(R.string.settings_delete_wallet_explanation_description) - .positiveButton(R.string.settings_delete_wallet_explanation_action, null) - .build() - showDialog(muunDialog) + override fun handleDeleteWallet(isActionBlocked: Boolean, isRecoverableUser: Boolean) { + if (isActionBlocked) { + showCantDeleteNonEmptyWalletDialog() + } else { - showDeleteWalletDialog() + showDeleteWalletDialog(isRecoverableUser) } } + override fun showCantDeleteNonEmptyWalletDialog() { + val muunDialog = MuunDialog.Builder() + .layout(R.layout.dialog_custom_layout2) + .title(R.string.settings_delete_wallet_explanation_title) + .message(R.string.settings_delete_wallet_explanation_description) + .positiveButton(R.string.settings_delete_wallet_explanation_action, null) + .build() + showDialog(muunDialog) + } + /** * Show a confirmation dialog, then delete wallet. */ - private fun showDeleteWalletDialog() { + private fun showDeleteWalletDialog(isRecoverableUser: Boolean) { + val messageResId = if (isRecoverableUser) { + R.string.settings_delete_wallet_alert_body_recoverable_user + } else { + R.string.settings_delete_wallet_alert_body_unrecoverable_user + } + val muunDialog = MuunDialog.Builder() .layout(R.layout.dialog_custom_layout2) .title(R.string.settings_delete_wallet_alert_title) - .message(R.string.settings_delete_wallet_alert_body) + .message(messageResId) .positiveButton(R.string.settings_delete_wallet_alert_yes) { presenter.deleteWallet() } .negativeButton(R.string.settings_delete_wallet_alert_no, null) .build() diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt index c9bb4769..c8c0406f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt @@ -6,12 +6,14 @@ import io.muun.apollo.data.external.NotificationService import io.muun.apollo.domain.NightModeManager import io.muun.apollo.domain.action.UserActions import io.muun.apollo.domain.action.base.ActionState +import io.muun.apollo.domain.action.user.DeleteWalletAction import io.muun.apollo.domain.action.user.UpdateProfilePictureAction import io.muun.apollo.domain.analytics.AnalyticsEvent -import io.muun.apollo.domain.analytics.AnalyticsEvent.E_LOG_OUT -import io.muun.apollo.domain.analytics.AnalyticsEvent.E_WALLET_DELETED import io.muun.apollo.domain.analytics.AnalyticsEvent.S_SETTINGS +import io.muun.apollo.domain.analytics.AnalyticsEvent.WalletDeleteState import io.muun.apollo.domain.errors.MuunError +import io.muun.apollo.domain.errors.delete_wallet.NonEmptyWalletDeleteException +import io.muun.apollo.domain.errors.delete_wallet.UnsettledOperationsWalletDeleteException import io.muun.apollo.domain.libwallet.UAF_TAPROOT import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.model.ExchangeRateWindow @@ -27,6 +29,7 @@ import io.muun.apollo.presentation.ui.base.di.PerFragment import io.muun.apollo.presentation.ui.settings.bitcoin.BitcoinSettingsFragment import io.muun.apollo.presentation.ui.settings.lightning.LightningSettingsFragment import io.muun.common.api.messages.EventCommunicationMessage.Event +import io.muun.common.utils.Preconditions import rx.Observable import timber.log.Timber import javax.inject.Inject @@ -36,6 +39,7 @@ import javax.money.CurrencyUnit class SettingsPresenter @Inject constructor( private val bitcoinUnitSel: BitcoinUnitSelector, private val updateProfilePictureAction: UpdateProfilePictureAction, + private val deleteWallet: DeleteWalletAction, private val userActions: UserActions, private val exchangeRateSelector: ExchangeRateSelector, private val userActivatedFeatureStatusSel: UserActivatedFeatureStatusSelector, @@ -55,6 +59,7 @@ class SettingsPresenter @Inject constructor( setUpUserWatcher() setUpUpdateProfilePictureAction() setUpUpdatePrimaryCurrencyAction() + setUpDeleteWalletAction() setUpNightMode() } @@ -109,6 +114,16 @@ class SettingsPresenter @Inject constructor( subscribeTo(observable) } + private fun setUpDeleteWalletAction() { + val observable = deleteWallet + .state + .compose(handleStates(view::setLoading, this::handleWalletDeleteError)) + .doOnNext { + onWalletDeleted() + } + subscribeTo(observable) + } + private fun setUpNightMode() { view.setNightMode(nightModeManager.get()) } @@ -145,7 +160,7 @@ class SettingsPresenter @Inject constructor( * Call to logout. */ fun logout() { - analytics.report(E_LOG_OUT()) + analytics.report(AnalyticsEvent.E_LOG_OUT()) analytics.resetUserProperties() // We need to "capture" auth header to fire (and forget) notifyLogout request @@ -162,34 +177,48 @@ class SettingsPresenter @Inject constructor( * Call to delete wallet. */ fun deleteWallet() { - analytics.report(E_WALLET_DELETED()) + analytics.report(AnalyticsEvent.E_WALLET_DELETE(WalletDeleteState.STARTED)) + deleteWallet.run() + } + + /** + * Perform successful delete wallet follow up actions (e.g cleanup, navigation, etc...). + */ + private fun onWalletDeleted() { + analytics.report(AnalyticsEvent.E_WALLET_DELETE(WalletDeleteState.SUCCESS)) analytics.resetUserProperties() - // We need to "capture" auth header to fire (and forget) notifyLogout request - val jwt = getJwt() navigator.navigateToDeleteWallet(context) // We need to finish this activity, or the session status check will immediately raise - // the SessionExpired error -- even though this was a regular logout. + // the SessionExpired error -- even though this is expected from deleteWallet. view.finishActivity() - userActions.notifyLogoutAction.run(jwt) } /** - * Handle the tap on the delete wallet or log out buttons. + * Handle the tap on the log out button. */ - fun handleDeleteWalletRequest() { + fun handleLogoutRequest() { // TODO: this should not be using a blocking observable. Not terrible, not ideal. val options = logoutOptionsSel.watch() .toBlocking() .first() val shouldBlockAndExplain = options.isBlocked() - if (options.isRecoverable()) { - view.handleLogout(shouldBlockAndExplain) - } else { - view.handleDeleteWallet(shouldBlockAndExplain) - } + Preconditions.checkArgument(options.isRecoverable()) + view.handleLogout(shouldBlockAndExplain) + } + + /** + * Handle the tap on the delete wallet button. + */ + fun handleDeleteWalletRequest() { + // TODO: this should not be using a blocking observable. Not terrible, not ideal. + val options = logoutOptionsSel.watch() + .toBlocking() + .first() + + view.handleDeleteWallet(options.isBlocked(), options.isRecoverable()) } override fun getEntryEvent(): AnalyticsEvent { @@ -224,4 +253,13 @@ class SettingsPresenter @Inject constructor( fun openDebugPanel() { navigator.navigateToDebugPanel(context) } + + private fun handleWalletDeleteError(error: Throwable?) { + analytics.report(AnalyticsEvent.E_WALLET_DELETE(WalletDeleteState.ERROR)) + when (error) { + is NonEmptyWalletDeleteException -> view.showCantDeleteNonEmptyWalletDialog() + is UnsettledOperationsWalletDeleteException -> view.showCantDeleteNonEmptyWalletDialog() + else -> super.handleError(error) + } + } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsView.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsView.java index 24c36a43..b079c19c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsView.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsView.java @@ -42,6 +42,11 @@ public interface SettingsView extends BaseView { /** * Handle the delete wallet action. */ - void handleDeleteWallet(boolean shouldDisplayDeleteWalletExplanation); + void handleDeleteWallet(boolean isActionBlocked, boolean isRecoverableUser); + /** + * Show a simple, standard muun error dialog to communicate that non empty wallet can't be + * deleted. + */ + void showCantDeleteNonEmptyWalletDialog(); } diff --git a/android/apolloui/src/main/res/layout-land/fragment_settings.xml b/android/apolloui/src/main/res/layout-land/fragment_settings.xml index ad7bc133..dec62ba9 100644 --- a/android/apolloui/src/main/res/layout-land/fragment_settings.xml +++ b/android/apolloui/src/main/res/layout-land/fragment_settings.xml @@ -169,7 +169,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/selectableItemBackground" - android:orientation="vertical"> + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> + + + + + + + + + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> + + + + + + Eliminar monedero - ¿Quieres eliminar tu moendero? + Por favor, lee atentamente - - Perderás tu historial de transacciones. + + Estás a punto de eliminar tu monedero de forma permanente. Si continúas, no podrás + recuperar tu billetera y también perderás tu historial de pagos. + + + Estás a punto de eliminar tu monedero de forma permanente. Si continúas, perderás tu + historial de pagos. Eliminar Cancelar @@ -796,11 +801,11 @@ SALIR - No puedes eliminar tu monedero en este momento + Por favor, vacía tu monedero - Necesitas vaciar tu monedero. Realiza un pago con todos tus fondos - y vuelve a intentarlo cuando la transacción tenga 6 confirmaciones. + Tu monedero aún tiene fondos. Para eliminarlo, por favor transfiere todos tus fondos y + espera hasta que la transacción tenga 6 confirmaciones. OK diff --git a/android/apolloui/src/main/res/values/strings.xml b/android/apolloui/src/main/res/values/strings.xml index f122dfb4..b37e33e4 100644 --- a/android/apolloui/src/main/res/values/strings.xml +++ b/android/apolloui/src/main/res/values/strings.xml @@ -749,10 +749,15 @@ Delete wallet - Are you sure? + Please read carefully - - You will lose your transaction history. + + You are about to permanently delete your wallet. If you proceed, you won\'t be able to + recover your wallet, and you\'ll also lose your payment history. + + + You are about to permanently delete your wallet. If you proceed, you\'ll lose your payment + history. Delete Cancel @@ -765,11 +770,11 @@ FINISH - You can\'t delete your wallet at this moment + Please empty your wallet - You need to empty your wallet. Make a payment using all your funds, - and try again when the transaction has 6 confirmations. + Your wallet still has funds. To delete it, please move all your funds out and wait until + the transaction has 6 confirmations. OK diff --git a/common/src/main/java/io/muun/common/api/error/ErrorCode.java b/common/src/main/java/io/muun/common/api/error/ErrorCode.java index 471b0400..c8ff5944 100644 --- a/common/src/main/java/io/muun/common/api/error/ErrorCode.java +++ b/common/src/main/java/io/muun/common/api/error/ErrorCode.java @@ -225,6 +225,12 @@ public enum ErrorCode { INCOMING_SWAP_ALREADY_FULFILLED( 2074, StatusCode.CLIENT_FAILURE, "The incoming swap is already fulfilled" ), + WALLET_NOT_EMPTY( + 2086, StatusCode.CLIENT_FAILURE, "Cannot delete wallet with funds" + ), + UNSETTLED_OPERATIONS( + 2087, StatusCode.CLIENT_FAILURE, "Cannot delete wallet with unsettled operations" + ), // error responses @Deprecated diff --git a/common/src/main/java/io/muun/common/api/houston/HoustonService.java b/common/src/main/java/io/muun/common/api/houston/HoustonService.java index fdde4fba..910cc863 100644 --- a/common/src/main/java/io/muun/common/api/houston/HoustonService.java +++ b/common/src/main/java/io/muun/common/api/houston/HoustonService.java @@ -218,6 +218,12 @@ Observable finishPasswordChange( @PUT("user/preferences") Completable updateUserPreferences(@Body UserPreferences userPreferences); + @POST("user/delete") + Completable deleteWallet(@Body ChallengeSignatureJson challengeSignatureJson); + + @POST("user/email/account-deletion/confirm") + Observable confirmAccountDeletion(@Body LinkActionJson linkActionJson); + // --------------------------------------------------------------------------------------------- // Contacts: diff --git a/common/src/main/java/io/muun/common/rx/CompletableFn.java b/common/src/main/java/io/muun/common/rx/CompletableFn.java index 7e582189..46ef1a9d 100644 --- a/common/src/main/java/io/muun/common/rx/CompletableFn.java +++ b/common/src/main/java/io/muun/common/rx/CompletableFn.java @@ -23,7 +23,8 @@ private CompletableFn() { */ public static Completable.Transformer replaceTypedError( final Class errorClass, - final Func1 replacer) { + final Func1 replacer + ) { return onTypedErrorResumeNext( errorClass, @@ -35,7 +36,8 @@ public static Completable.Transformer replaceTypedErr * Consume and ignore errors of a given type. */ public static Completable.Transformer ignoreTypedError( - final Class errorClass) { + final Class errorClass + ) { return onTypedErrorResumeNext( errorClass, @@ -48,7 +50,8 @@ public static Completable.Transformer ignoreTypedErro */ public static Completable.Transformer onTypedErrorResumeNext( final Class errorClass, - final Func1 resumeFunction) { + final Func1 resumeFunction + ) { return completable -> completable.onErrorResumeNext(error -> { @@ -74,12 +77,28 @@ public static Completable.Transformer ignoreHttpException(final ErrorCode code) ); } + /** + * If the error emitted by the completable is of type HttpException and has a specific code, it + * gets replaced by the error returned when calling replacer with the original error. + */ + public static Completable.Transformer replaceHttpException( + final ErrorCode code, + final Func1 replacer + ) { + + return onHttpExceptionResumeNext( + code, + error -> Completable.error(replacer.call(error)) + ); + } + /** * Like onTypedErrorResumeNext, but specialized to HttpExceptions. */ public static Completable.Transformer onHttpExceptionResumeNext( final ErrorCode code, - final Func1 resumeFunction) { + final Func1 resumeFunction + ) { return completable -> completable.onErrorResumeNext(error -> {