From cce2fcf9284cbecd651373282d434c72374304c5 Mon Sep 17 00:00:00 2001 From: acrespo Date: Thu, 19 Oct 2023 13:22:01 -0300 Subject: [PATCH] Apollo: Release source code for 51.1 --- android/CHANGELOG.md | 32 ++++ .../data/analytics/AnalyticsProvider.kt | 4 +- .../io/muun/apollo/data/debug/HeapDumper.kt | 44 ++++++ .../io/muun/apollo/data}/debug/LappClient.kt | 37 +++-- .../muun/apollo/data/debug/LappClientError.kt | 3 + .../apollo/data}/debug/SimpleHttpClient.kt | 2 +- .../external/{Globals.java => Globals.kt} | 69 +++++---- .../muun/apollo/data/logging/Crashlytics.kt | 31 ++-- .../io/muun/apollo/data/logging/MuunTree.kt | 21 ++- .../muun/apollo/data/os/ClipboardProvider.kt | 21 ++- .../data/os/HardwareCapabilitiesProvider.kt | 2 +- .../secure_storage/SecureStorageProvider.java | 15 +- .../data/preferences/RepositoryRegistry.kt | 30 +++- .../preferences/UserPreferencesRepository.kt | 2 - .../data/preferences/UserRepository.java | 31 +++- .../apollo/domain/ApplicationLockManager.java | 34 ++--- .../io/muun/apollo/domain/ClipboardManager.kt | 6 +- .../apollo/domain/NotificationProcessor.java | 5 +- .../apollo/domain/ShowWelcomeToMuunManager.kt | 16 ++ .../apollo/domain/action/LogoutActions.java | 5 +- .../action/NotificationActions+Extensions.kt | 11 ++ .../domain/action/NotificationActions.java | 37 +++-- .../domain/action/di/ActionComponent.java | 3 + .../action/ek/RenderEmergencyKitAction.kt | 7 +- .../action/fcm/UpdateFcmTokenAction.java | 7 +- .../action/integrity/GooglePlayIntegrity.kt | 9 +- .../operation/CreateOperationAction.java | 9 +- .../action/session/DetectAppUpdateAction.kt | 3 +- .../session/rc_only/LogInWithRcAction.kt | 6 +- .../apollo/domain/analytics/AnalyticsEvent.kt | 10 +- .../apollo/domain/debug/DebugExecutable.kt | 84 +++++++++++ .../errors/WeirdIncorrectAttemptsBugError.kt | 8 +- .../errors/debug/DebugExecutableError.kt | 3 + .../domain/selector/ClipboardUriSelector.kt | 25 ++- .../application_lock/ApplicationLockTest.java | 11 +- .../user/UpdateUserPreferencesActionTest.kt | 20 +-- android/apolloui/build.gradle | 6 +- .../presentation/BaseInstrumentationTest.kt | 29 ++++ .../apollo/presentation/IncomingSwapTests.kt | 2 +- .../apollo/presentation/LnUrlWithdrawTests.kt | 32 ++-- .../presentation/LoginAndSignUpTests.kt | 4 +- .../apollo/presentation/NewOperationTests.kt | 53 ++++++- .../muun/apollo/presentation/P2PSetupTests.kt | 13 +- .../java/io/muun/apollo/utils/AutoFlows.kt | 142 ++++++++++++------ .../java/io/muun/apollo/utils/Clipboard.kt | 10 +- .../java/io/muun/apollo/utils/MuunButton.kt | 2 +- .../java/io/muun/apollo/utils/MuunDialog.kt | 2 +- .../java/io/muun/apollo/utils/MuunTexts.kt | 3 +- .../java/io/muun/apollo/utils/MuunToolbar.kt | 2 +- .../io/muun/apollo/utils/SystemCommand.kt | 16 +- .../io/muun/apollo/utils/SystemContacts.kt | 12 +- .../java/io/muun/apollo/utils/TestData.kt | 4 +- .../java/io/muun/apollo/utils/UriPaster.kt | 2 +- .../apollo/utils/WithMuunEspressoHelpers.kt | 12 +- .../utils/WithMuunInstrumentationHelpers.kt | 79 +++++++--- .../java/io/muun/apollo/utils/adb.kt | 5 +- .../utils/screens/ChangePasswordScreen.kt | 7 +- .../utils/screens/EmailPasswordSetupScreen.kt | 7 +- .../utils/screens/EmergencyKitSetupScreen.kt | 22 ++- .../muun/apollo/utils/screens/HomeScreen.kt | 30 ++-- .../apollo/utils/screens/ManualFeeScreen.kt | 26 ++-- .../utils/screens/NewOperationScreen.kt | 71 +++++---- .../utils/screens/OperationDetailScreen.kt | 14 +- .../apollo/utils/screens/ReceiveScreen.kt | 11 +- .../utils/screens/RecommendedFeeScreen.kt | 6 +- .../utils/screens/SecurityCenterScreen.kt | 4 +- .../apollo/utils/screens/SettingsScreen.kt | 7 +- .../apollo/utils/screens/SetupP2PScreen.kt | 27 ++-- .../muun/apollo/utils/screens/SignInScreen.kt | 41 +++-- .../presentation/app/ApolloApplication.java | 7 +- .../apollo/presentation/app/GlobalsImpl.java | 105 ------------- .../apollo/presentation/app/GlobalsImpl.kt | 67 +++++++++ .../apollo/presentation/app/Navigator.java | 3 +- .../app/NotificationServiceImpl.kt | 8 +- .../extension/ApplicationLockExtension.java | 32 ++-- .../extension/BaseRequestExtension.kt | 7 +- .../extension/ExternalResultExtension.kt | 8 +- .../extension/ScreenshotBlockExtension.kt | 6 +- .../activity/extension/SnackBarExtension.java | 6 +- .../presentation/ui/base/BaseActivity.java | 14 +- .../presentation/ui/base/BaseFragment.java | 6 + .../ui/base/ExtensibleActivity.java | 2 +- .../ui/base/SingleFragmentActivityImpl.kt | 6 +- .../ui/debug/DebugPanelPresenter.java | 65 ++++---- .../presentation/ui/home/HomeActivity.java | 26 ++-- .../presentation/ui/home/HomePresenter.java | 25 ++- .../apollo/presentation/ui/home/HomeView.java | 11 ++ .../lnurl/withdraw/LnUrlWithdrawActivity.kt | 3 +- .../lnurl/withdraw/LnUrlWithdrawPresenter.kt | 17 ++- .../ui/new_operation/DisplayAmount.kt | 76 +++++++++- .../ui/new_operation/NewOperationActivity.kt | 79 +++++----- .../ui/new_operation/NewOperationForm.java | 47 ------ .../ui/new_operation/NewOperationPresenter.kt | 4 +- .../ui/new_operation/NewOperationView.kt | 8 +- .../ui/scan_qr/ScanQrActivity.java | 50 +++++- .../ui/scan_qr/ScanQrPresenter.java | 43 +++++- .../presentation/ui/scan_qr/ScanQrView.java | 21 +++ .../ui/select_amount/SelectAmountActivity.kt | 12 +- .../presentation/ui/send/SendActivity.kt | 57 +++++-- .../presentation/ui/send/SendPresenter.kt | 72 ++++++--- .../apollo/presentation/ui/send/SendView.kt | 12 +- .../apollo/presentation/ui/send/UriState.kt | 8 + .../presentation/ui/show_qr/QrFragment.kt | 8 + .../ui/signup/SignupPresenter.java | 6 +- .../muun/apollo/presentation/ui/utils/OS.kt | 23 ++- .../ui/utils/UiNotificationPoller.java | 73 --------- .../ui/utils/UiNotificationPoller.kt | 66 ++++++++ .../presentation/ui/view/FeeManualInput.java | 4 +- .../presentation/ui/view/MuunAmountInput.java | 52 +++++-- .../presentation/ui/view/MuunLockOverlay.java | 124 +++------------ .../ui/view/MuunPictureInput.java | 4 +- .../ui/view/MuunRecoveryCodeBox.java | 4 + .../presentation/ui/view/MuunTextInput.java | 4 +- .../presentation/ui/view/MuunUriInput.kt | 1 - .../apollo/presentation/ui/view/MuunView.kt | 10 +- .../src/main/res/layout/activity_send.xml | 12 +- .../main/res/layout/fragment_introduction.xml | 3 +- .../res/layout/fragment_rc_only_login.xml | 3 +- .../src/main/res/layout/scan_qr_activity.xml | 14 +- .../res/layout/view_paste_from_clipboard.xml | 28 ++++ .../src/main/res/values-es/strings.xml | 6 +- .../apolloui/src/main/res/values/strings.xml | 6 +- .../presenters/VerifyEmailPresenterTest.kt | 60 -------- .../ui/new_operation/DisplayAmountTest.kt | 81 ++++++++++ .../io/muun/common/utils/CollectionUtils.java | 25 +++ .../io/muun/common/utils/Preconditions.java | 18 ++- .../io/muun/common/utils/HexUtilsTest.java | 54 +++++++ .../java/io/muun/common/utils/HexUtilsTest.kt | 61 -------- libwallet/fees/fees.go | 16 +- libwallet/lnurl/lnurl.go | 2 + libwallet/operation/payment_analyzer.go | 69 ++++++--- 131 files changed, 1977 insertions(+), 1139 deletions(-) create mode 100644 android/apollo/src/main/java/io/muun/apollo/data/debug/HeapDumper.kt rename android/{apolloui/src/main/java/io/muun/apollo/presentation/ui => apollo/src/main/java/io/muun/apollo/data}/debug/LappClient.kt (72%) create mode 100644 android/apollo/src/main/java/io/muun/apollo/data/debug/LappClientError.kt rename android/{apolloui/src/main/java/io/muun/apollo/presentation/ui => apollo/src/main/java/io/muun/apollo/data}/debug/SimpleHttpClient.kt (98%) rename android/apollo/src/main/java/io/muun/apollo/data/external/{Globals.java => Globals.kt} (56%) create mode 100644 android/apollo/src/main/java/io/muun/apollo/domain/ShowWelcomeToMuunManager.kt create mode 100644 android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions+Extensions.kt create mode 100644 android/apollo/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt create mode 100644 android/apollo/src/main/java/io/muun/apollo/domain/errors/debug/DebugExecutableError.kt delete mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.java create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.kt delete mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationForm.java create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/UriState.kt delete mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/UiNotificationPoller.java create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/UiNotificationPoller.kt create mode 100644 android/apolloui/src/main/res/layout/view_paste_from_clipboard.xml delete mode 100644 android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/VerifyEmailPresenterTest.kt create mode 100644 android/apolloui/src/test/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmountTest.kt create mode 100644 common/src/test/java/io/muun/common/utils/HexUtilsTest.java delete mode 100644 common/src/test/java/io/muun/common/utils/HexUtilsTest.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 0973f5ea..ab8d968f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -6,6 +6,38 @@ follow [https://changelog.md/](https://changelog.md/) guidelines. ## [Unreleased] +## [51.1] - 2023-10-17 + +### ADDED + +- Special UI warning message when performing a cyclical payment using the last copied address from +the receive screen. +- Added and enhanced debugging data, specially for notification processing. + +### FIXED + +- A bug that messed up currency rotation (e.g currencies rotate when clicked upon) in New Operation +screen. +- A bug where duplicated UI events firing in a short range from messing with secure storage's + Keystore. +- Stop polling for notifications upon an ExpiredSession error. Avoid wasting resources and +generating backend alerts. +- A visual glitch in lnurl withdraw unresponsive error handling. +- A bug in lnurl withdraw flow when manually inputting the lnurl in the Send screen. +- A bug where the "Welcome to Muun" dialog would be displayed more than once if the Home activity +was recreated. +- Several memory leaks regarding QRs bitmaps and Repository registry. + +### CHANGED + +- Special UI component to "paste from clipboard" to adapt to Android's clipboard access notification +on Android 12+. We no longer automatically read from clipboard in Android12+, only upon user +request. +- Satoshis copy in Select Bitcoin Unit screen. Now explicitly naming the option Satoshi (SAT), + instead of Bitcoin (SAT). +- Silence noisy DRM errors. +- Huge revamp to UI test suite. Enhancing reliability o coverage. + ## [51] - 2023-07-28 ### ADDED diff --git a/android/apollo/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt b/android/apollo/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt index 1bf959a5..97580f47 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt @@ -3,11 +3,11 @@ package io.muun.apollo.data.analytics import android.content.Context import android.content.res.Resources import android.os.Bundle +import android.util.Log import com.google.firebase.analytics.FirebaseAnalytics import io.muun.apollo.domain.analytics.AnalyticsEvent import io.muun.apollo.domain.model.report.CrashReport import io.muun.apollo.domain.model.user.User -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -64,7 +64,7 @@ class AnalyticsProvider @Inject constructor(val context: Context) { fba.logEvent(event.eventId, bundle) inMemoryMapBreadcrumbCollector[event.eventId] = bundle - Timber.i(event.toString()) + Log.i("AnalyticsProvider", event.toString()) } private fun getBreadcrumbMetadata(): String { diff --git a/android/apollo/src/main/java/io/muun/apollo/data/debug/HeapDumper.kt b/android/apollo/src/main/java/io/muun/apollo/data/debug/HeapDumper.kt new file mode 100644 index 00000000..b0260e60 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/data/debug/HeapDumper.kt @@ -0,0 +1,44 @@ +package io.muun.apollo.data.debug + +import android.app.Application +import android.os.Debug +import io.muun.apollo.data.external.Globals +import timber.log.Timber +import java.io.IOException + +/** + * Utility class to help the debug and analysis of OutOfMemoryError (OOM) and memory leaks. Meant + * to be used ONLY for DEBUG builds. + */ +object HeapDumper { + + private lateinit var application: Application + + //uncaught exceptions + private var defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null + + @JvmStatic + fun init(application: Application) { + if (Globals.INSTANCE.isDebugBuild) { + this.application = application + defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(customUncaughtExceptionHandler) + } + } + + // OOM handler listener + private val customUncaughtExceptionHandler = Thread.UncaughtExceptionHandler { thread, ex -> + if (ex is OutOfMemoryError) { + try { + Timber.i("DumpHprofData: Starting...") + Debug.dumpHprofData("${application.filesDir.absolutePath}/apollo-oom-dump.hprof") + } catch (e: IOException) { + Timber.i("DumpHprofData: Error: $e. Cause: $ex") + } + Timber.i("DumpHprofData: Success") + } + + //call the default exception handler + defaultUncaughtExceptionHandler?.uncaughtException(thread, ex) + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/LappClient.kt b/android/apollo/src/main/java/io/muun/apollo/data/debug/LappClient.kt similarity index 72% rename from android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/LappClient.kt rename to android/apollo/src/main/java/io/muun/apollo/data/debug/LappClient.kt index 3c9887a7..0da34bdb 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/LappClient.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/debug/LappClient.kt @@ -1,6 +1,5 @@ -package io.muun.apollo.presentation.ui.debug +package io.muun.apollo.data.debug -import io.muun.apollo.BuildConfig import io.muun.apollo.data.external.Globals import io.muun.common.utils.LnInvoice import okhttp3.Response @@ -18,20 +17,27 @@ class LappClient : SimpleHttpClient() { EXPIRED_LNURL("expiredLnurl"), NO_ROUTE("noRoute"), WRONG_TAG("wrongTag"), - UNRESPONSIVE("unresponsive") + UNRESPONSIVE("unresponsive"), + UNRESPONSIVE_LNURL_SERVICE("unresponsiveLnurlService") // service that generates LNURL } - private val url = BuildConfig.LAPP_URL + private val url = Globals.INSTANCE.lappUrl - private fun executeNow(request: Observable): String { + private fun executeNow(request: Observable): Response { val response = request.toBlocking().first()!! - return response.body()!!.string() + + // Simple error handling will do for now + if (response.code() in 400..599) { + throw LappClientError(response.message() + ": " + response.bodyAsString()) + } + + return response } fun getLnInvoice(amountInSats: Int): LnInvoice { val request = get("$url/invoice?satoshis=$amountInSats") - val htmlString = executeNow(request) + val htmlString = executeNow(request).bodyAsString() val invoiceString = htmlString .substringBefore("") @@ -65,21 +71,27 @@ class LappClient : SimpleHttpClient() { } /** - * Return a new withdraw LNURL. Receives a variant param to generate different LNRULs to force + * Return a new withdraw LNURL. Receives a variant param to generate different LNURLs to force * different use cases. */ fun generateWithdrawLnUrl(variant: LnUrlVariant = LnUrlVariant.NORMAL): String { - if (variant == LnUrlVariant.UNRESPONSIVE) { + if (variant == LnUrlVariant.UNRESPONSIVE_LNURL_SERVICE) { // Hard-coded lnurl to force "unresponsive service" response + // Encoded uri is: https://this.domain.does.not.exist.example.com?secret=12345 return "LNURL1DP68GURN8GHJ7ARGD9EJUER0D4SKJM3WV3HK2UEWDEHHGTN90P5HXAPWV4UXZMTSD3JJUC" + "M0D5LHXETRWFJHG0F3XGENGDGQ8EH52" + } - val request = get("$url/lnurl/withdrawStart?variant=${variant.value}&block=true") + // For SLOW ui tests we need the async behavior of the lapp's lnurl withdraw flow. Otherwise + // Receiving state is never reached (e.g cause the withdraw fullfill request ends after the + // ln payment is completed). + val blocking = variant != LnUrlVariant.SLOW + val request = get("$url/lnurl/withdrawStart?variant=${variant.value}&block=$blocking") val response = executeNow(request) - return response.trim() + return response.bodyAsString() } /** @@ -114,4 +126,7 @@ class LappClient : SimpleHttpClient() { val request = post("$url/undrop?tx=$txId", "") executeNow(request) } + + private fun Response.bodyAsString(): String = + body()!!.string().trim() } diff --git a/android/apollo/src/main/java/io/muun/apollo/data/debug/LappClientError.kt b/android/apollo/src/main/java/io/muun/apollo/data/debug/LappClientError.kt new file mode 100644 index 00000000..f75e71ca --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/data/debug/LappClientError.kt @@ -0,0 +1,3 @@ +package io.muun.apollo.data.debug + +class LappClientError(override val message: String) : RuntimeException() \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/SimpleHttpClient.kt b/android/apollo/src/main/java/io/muun/apollo/data/debug/SimpleHttpClient.kt similarity index 98% rename from android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/SimpleHttpClient.kt rename to android/apollo/src/main/java/io/muun/apollo/data/debug/SimpleHttpClient.kt index 5bb33e66..75670e16 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/SimpleHttpClient.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/debug/SimpleHttpClient.kt @@ -1,4 +1,4 @@ -package io.muun.apollo.presentation.ui.debug +package io.muun.apollo.data.debug import android.os.AsyncTask import okhttp3.MediaType diff --git a/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.java b/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt similarity index 56% rename from android/apollo/src/main/java/io/muun/apollo/data/external/Globals.java rename to android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt index 9e7187c8..f8d3657f 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/external/Globals.kt @@ -1,112 +1,119 @@ -package io.muun.apollo.data.external; +package io.muun.apollo.data.external -import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.NetworkParameters +abstract class Globals { -public abstract class Globals { + companion object { - /** - * This will be initialized in the UI application code, since it depends on Android build - * configurations. - */ - public static Globals INSTANCE; + /** + * This will be initialized in the UI application code, since it depends on Android build + * configurations. + */ + lateinit var INSTANCE: Globals + } /** * Get the Application Id (previously package name) of the app. Identifies the app on the * device, its unique in the Google Play store. */ - public abstract String getApplicationId(); + abstract val applicationId: String /** * Get whether the current build a debuggable build. */ - public abstract boolean isDebugBuild(); + abstract val isDebugBuild: Boolean /** * Get the build type of the current build. */ - public abstract String getBuildType(); + abstract val buildType: String /** * Get the legacy build type of the current build. It is now deprecated in favour of - * {@link Globals#getBuildType()}. + * [Globals.buildType]. */ - public abstract String getOldBuildType(); + abstract val oldBuildType: String /** * Get the version code of the current build (e.g 1004). */ - public abstract int getVersionCode(); + abstract val versionCode: Int /** * Get the version name of the current build (e.g 50.4). */ - public abstract String getVersionName(); + abstract val versionName: String /** * Get the version name of the current build (e.g 50.4). */ - public abstract String getDeviceName(); + abstract val deviceName: String /** * Get the model name of the device where app is running. */ - public abstract String getDeviceModel(); + abstract val deviceModel: String /** * Get the manufacturer name of the device where app is running. */ - public abstract String getDeviceManufacturer(); + abstract val deviceManufacturer: String /** * Get the fingerprint of the device where app is running. */ - public abstract String getFingerprint(); + abstract val fingerprint: String /** * Get the hardware name of the device where app is running. */ - public abstract String getHardware(); + abstract val hardware: String /** * Get the bootloader name of the device where app is running. */ - public abstract String getBootloader(); + abstract val bootloader: String /** * Get the bitcoin network specs/parameters of the network this build is using. */ - public abstract NetworkParameters getNetwork(); + abstract val network: NetworkParameters /** * Get the hostname of this app's deeplink. */ - public abstract String getMuunLinkHost(); + abstract val muunLinkHost: String /** * Get the path of this app's "Verify" deeplink. */ - public abstract String getVerifyLinkPath(); + abstract val verifyLinkPath: String /** * Get the path of this app's "Authorize" deeplink. */ - public abstract String getAuthorizeLinkPath(); + abstract val authorizeLinkPath: String /** * Get the path of this app's "Confirm" deeplink. */ - public abstract String getConfirmLinkPath(); + abstract val confirmLinkPath: String /** * Get the path of this app's "Authorize RC Login" deeplink. */ - public abstract String getRcLoginAuthorizePath(); + abstract val rcLoginAuthorizePath: String + + /** + * Get Lapp's URL. + */ + abstract val lappUrl: String /** * Get whether the current build is a release build. */ - public boolean isReleaseBuild() { - return getBuildType().equals("release"); - } -} + val isReleaseBuild: Boolean + get() = buildType == "release" + +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt b/android/apollo/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt index 556e61fb..509f72ab 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt @@ -16,7 +16,6 @@ import io.muun.apollo.domain.errors.newop.NoPaymentRouteException import io.muun.apollo.domain.errors.newop.UnreachableNodeException import io.muun.apollo.domain.model.report.CrashReport import io.muun.apollo.domain.utils.isInstanceOrIsCausedByError -import timber.log.Timber object Crashlytics { @@ -26,7 +25,12 @@ object Crashlytics { null } - private var application: Application? = null + private var analytics: Analytics? = null + + @JvmStatic + fun init(application: Application) { + this.analytics = Analytics(AnalyticsProvider(application)) + } /** * Set up Crashlytics metadata. @@ -44,16 +48,14 @@ object Crashlytics { * https://firebase.google.com/docs/crashlytics/customize-crash-reports?platform=android#add-logs */ @JvmStatic + @Deprecated("Not really but you shouldn't use this directly. Use Timber.i(). See MuunTree.") fun logBreadcrumb(breadcrumb: String) { - Timber.d("Breadcrumb: $breadcrumb") crashlytics?.log(breadcrumb) - application?.applicationContext?.let { - Analytics(AnalyticsProvider(it)).report( - AnalyticsEvent.E_BREADCRUMB( - breadcrumb - ) + analytics?.report( + AnalyticsEvent.E_BREADCRUMB( + breadcrumb ) - } + ) } /** @@ -74,6 +76,12 @@ object Crashlytics { crashlytics?.setCustomKey(entry.key, entry.value.toString()) } + analytics?.report( + AnalyticsEvent.E_CRASHLYTICS_ERROR( + report.error.javaClass.simpleName + ":" + report.error.localizedMessage + ) + ) + crashlytics?.recordException(report.error) } @@ -136,9 +144,4 @@ object Crashlytics { else -> false } } - - @JvmStatic - fun init(application: Application) { - this.application = application - } } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/data/logging/MuunTree.kt b/android/apollo/src/main/java/io/muun/apollo/data/logging/MuunTree.kt index 87b74eb3..a2235327 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/logging/MuunTree.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/logging/MuunTree.kt @@ -1,6 +1,7 @@ package io.muun.apollo.data.logging import android.util.Log +import io.muun.apollo.data.logging.Crashlytics.logBreadcrumb import io.muun.apollo.domain.model.report.CrashReportBuilder import timber.log.Timber @@ -10,13 +11,27 @@ class MuunTree : Timber.DebugTree() { * Log a message, taking steps to enrich and report errors. */ override fun log(priority: Int, tag: String?, message: String?, error: Throwable?) { - // For non-error logs, we don't have any special treatment: - if (priority < Log.ERROR) { + // For low priority logs, we don't have any special treatment: + if (priority < Log.INFO) { super.log(priority, tag, message, error) return } - sendCrashReport(tag, message, error) + when (priority) { + Log.INFO -> { + Log.i("Breadcrumb", message!!) + @Suppress("DEPRECATION") // I know. These are the only allowed usages. + logBreadcrumb(message) + } + Log.WARN -> { + Log.w(tag, message!!) + @Suppress("DEPRECATION") // I know. These are the only allowed usages. + logBreadcrumb("Warning: $message") + } + else -> { // Log.ERROR && Log.ASSERT + sendCrashReport(tag, message, error) + } + } } private fun sendCrashReport(tag: String?, message: String?, error: Throwable?) { diff --git a/android/apollo/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt b/android/apollo/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt index c0ae9008..a9dcf257 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt @@ -1,6 +1,7 @@ package io.muun.apollo.data.os import android.content.ClipData +import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN import android.content.ClipboardManager import android.content.Context import rx.Observable @@ -53,8 +54,10 @@ class ClipboardProvider @Inject constructor(context: Context) { /** * Grab some text from the clipboard. + * NOTE: starting from Android 12 (api 31) this triggers a Clipboard Access visual notification + * on screen. */ - private fun paste(): String? { + fun paste(): String? { if (!clipboard.hasPrimaryClip()) { return null } @@ -65,4 +68,20 @@ class ClipboardProvider @Inject constructor(context: Context) { val item = primaryClip.getItemAt(0) return item?.text?.toString() } + + /** + * Create an Observable that reports changes on the system clipboard (fires on subscribe). + */ + fun watchForPlainText(): Observable { + return Observable + .interval(250, TimeUnit.MILLISECONDS) // emits sequential numbers 0+ on each tick + .startWith(-1L) // emits -1 immediately (since interval waits for the first delay) + .map { hasPlainText() } + .distinctUntilChanged() + } + + private fun hasPlainText(): Boolean { + return clipboard.hasPrimaryClip() + && clipboard.primaryClipDescription!!.hasMimeType(MIMETYPE_TEXT_PLAIN) + } } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/data/os/HardwareCapabilitiesProvider.kt b/android/apollo/src/main/java/io/muun/apollo/data/os/HardwareCapabilitiesProvider.kt index cbffaf63..45f9ff16 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/os/HardwareCapabilitiesProvider.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/os/HardwareCapabilitiesProvider.kt @@ -230,7 +230,7 @@ class HardwareCapabilitiesProvider @Inject constructor(private val context: Cont } catch (e: Exception) { // These two drm provider often return errors though they are listed as "supported" - if (drmProviderUuid != COMMON_PSSH_UUID || drmProviderUuid != CLEARKEY_UUID) { + if (drmProviderUuid != COMMON_PSSH_UUID && drmProviderUuid != CLEARKEY_UUID) { Timber.e(DrmProviderError(drmProviderUuid, e)) } return null diff --git a/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStorageProvider.java b/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStorageProvider.java index 693d38dd..3d0ce9cc 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStorageProvider.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStorageProvider.java @@ -1,10 +1,8 @@ package io.muun.apollo.data.os.secure_storage; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.domain.errors.SecureStorageError; import io.muun.common.utils.Encodings; -import androidx.annotation.VisibleForTesting; import rx.Observable; import timber.log.Timber; @@ -154,7 +152,7 @@ public void wipe() { @GuardedBy("lock") private void throwIfModeInconsistent() { if (!preferences.isCompatibleFormat()) { - Crashlytics.logBreadcrumb("InconsistentModeError"); + Timber.i("InconsistentModeError"); throw new InconsistentModeError(debugSnapshot()); } } @@ -165,17 +163,17 @@ private void throwIfKeyCorruptedOrMissing(String key) { final boolean hasKeyInKeystore = keyStore.hasKey(key); if (!hasKeyInKeystore && !hasKeyInPreferences) { - Crashlytics.logBreadcrumb("SecureStorageNoSuchElementError for key: " + key); + Timber.i("SecureStorageNoSuchElementError for key: " + key); throw new SecureStorageNoSuchElementError(key, debugSnapshot()); } if (!hasKeyInPreferences) { - Crashlytics.logBreadcrumb("SharedPreferencesCorruptedError for key: " + key); + Timber.i("SharedPreferencesCorruptedError for key: " + key); throw new SharedPreferencesCorruptedError(key, debugSnapshot()); } if (!hasKeyInKeystore) { - Crashlytics.logBreadcrumb("KeyStoreCorruptedError for key: " + key); + Timber.i("KeyStoreCorruptedError for key: " + key); throw new KeyStoreCorruptedError(key, debugSnapshot()); } } @@ -185,7 +183,7 @@ private byte[] retrieveDecrypted(String key) { try { return keyStore.decryptData(preferences.getBytes(key), key, preferences.getAesIv(key)); } catch (Throwable e) { - Crashlytics.logBreadcrumb("SecureStorageError on READ for key: " + key); + Timber.i("SecureStorageError on READ for key: " + key); final SecureStorageError ssError = new SecureStorageError(e, debugSnapshot()); enhanceError(ssError, key); throw ssError; @@ -197,7 +195,7 @@ private void storeEncrypted(String key, byte[] input) { try { preferences.saveBytes(keyStore.encryptData(input, key, preferences.getAesIv(key)), key); } catch (Throwable e) { - Crashlytics.logBreadcrumb("SecureStorageError on WRITE for key: " + key); + Timber.i("SecureStorageError on WRITE for key: " + key); final SecureStorageError ssError = new SecureStorageError(e, debugSnapshot()); enhanceError(ssError, key); throw ssError; @@ -214,7 +212,6 @@ private void enhanceError(SecureStorageError ssError, String key) { * Take a debug snapshot of the current state of the secure storage. This is safe to * report without compromising any user data. */ - @VisibleForTesting public DebugSnapshot debugSnapshot() { // NEVER ever return any values from the keystore itself, only labels should get out. diff --git a/android/apollo/src/main/java/io/muun/apollo/data/preferences/RepositoryRegistry.kt b/android/apollo/src/main/java/io/muun/apollo/data/preferences/RepositoryRegistry.kt index 5d80b0a5..1e5a3428 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/preferences/RepositoryRegistry.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/preferences/RepositoryRegistry.kt @@ -1,6 +1,7 @@ package io.muun.apollo.data.preferences import io.muun.apollo.data.preferences.permission.NotificationPermissionStateRepository +import timber.log.Timber /** * Welcome to RepositoryRegistry! This is class is meant to be a centralized, singleton registry @@ -14,10 +15,12 @@ import io.muun.apollo.data.preferences.permission.NotificationPermissionStateRep * (ie we want to keep that data) instead of the usual (rm -rf). * * Since we are still forced to manually keep a list of ALL repositories, we came up with the idea - * of having a static list of all the repositories clases, and having BaseRepository (the class + * of having a static list of all the repositories classes, and having BaseRepository (the class * which every repository should extend from), load the repository here upon its creation. * That way if we forget to add a new repository to this list, build will quickly fail upon running. * This is the simplest and "closest to a linter/static check" solution we could came up with. + * + * Note: this class is used a @Singleton. Check out its provider method in { @link DataModule }. */ class RepositoryRegistry { @@ -66,7 +69,17 @@ class RepositoryRegistry { NotificationPermissionSkippedRepository::class.java ) - private val loadedRepositories: MutableSet = mutableSetOf() + // Note: the use of a map is critical here for 2 reasons, both of them related to memory + // footprint. First, we want to keep exactly ONE reference to each repository, since that's all + // we need to perform the clearing of the repository at logout (dependency injection framework + // may instantiate more than one instance of the same repository if its not annotated with + // @Singleton). Secondly, we take advantage of the behavior of the put() method, to "renew" our + // reference of the repository and allow "older" references/instances to be properly garbage + // collected. This help prevents leaks of other objects which can't be GCed because they held + // a reference to that "old" repository instance (e.g objects where that repository was injected + // as a dependency). + private val loadedRepos: MutableMap, BaseRepository> = + mutableMapOf() fun load(repo: BaseRepository) { synchronized(lock) { @@ -74,7 +87,10 @@ class RepositoryRegistry { throw IllegalStateException("${repo.javaClass} is not registered in ${this.javaClass}!") } - loadedRepositories.add(repo) + loadedRepos[repo.javaClass] = repo + Timber.d( + "RepositoryRegistry#load(${repo.javaClass.simpleName}). Size: ${loadedRepos.size}" + ) } } @@ -82,11 +98,11 @@ class RepositoryRegistry { * The list of repositories to clear include all loaded repositories except for * logoutSurvivorRepositories. */ - fun repositoriesToClear(): List = + fun repositoriesToClearOnLogout(): Collection = synchronized(lock) { - loadedRepositories.filter { - !logoutSurvivorRepositories.contains(it.javaClass) - } + loadedRepos.filterKeys { + !logoutSurvivorRepositories.contains(it) + }.values } private fun isRegistered(repository: BaseRepository) = diff --git a/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserPreferencesRepository.kt b/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserPreferencesRepository.kt index dda73b2c..382afbb3 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserPreferencesRepository.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserPreferencesRepository.kt @@ -68,7 +68,6 @@ open class UserPreferencesRepository @Inject constructor( var skippedEmailSetup: Boolean = false var receivePreference: ReceiveFormatPreference = ReceiveFormatPreference.ONCHAIN - // JSON constructor constructor() @@ -92,5 +91,4 @@ open class UserPreferencesRepository @Inject constructor( ) } } - } diff --git a/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserRepository.java b/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserRepository.java index e0b3b92d..e47e3907 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserRepository.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/preferences/UserRepository.java @@ -58,6 +58,10 @@ public class UserRepository extends BaseRepository { private static final String TAPROOT_CELEBRATION_PENDING = "taproot_celebration_pending"; + private static final String SEEN_WELCOME_TO_MUUN_DIALOG_KEY = "seen_welcome_to_muun_dialog_key"; + + // Preferences: + private final Preference lastCopiedAddress; private final Preference pendingProfilePictureUriPreference; @@ -81,6 +85,8 @@ public class UserRepository extends BaseRepository { // Horrible I know, but only temporary until taproot activation date. Afterwards this goes away. private final Preference taprootCelebrationPending; + private final Preference seenWelcomeToMuunDialogPreference; + /** * Creates a user preference repository. */ @@ -132,6 +138,11 @@ public UserRepository(Context context, RepositoryRegistry repositoryRegistry) { TAPROOT_CELEBRATION_PENDING, false ); + + seenWelcomeToMuunDialogPreference = rxSharedPreferences.getBoolean( + SEEN_WELCOME_TO_MUUN_DIALOG_KEY, + false + ); } @Override @@ -269,15 +280,15 @@ public void setPendingProfilePictureUri(@Nullable Uri uri) { * Note: no longer necessary a bitcoin address, can be a Ln invoice. */ @Nullable - public String getLastCopiedAddress() { + public String getLastCopiedContentFromReceive() { return lastCopiedAddress.get(); } /** * Note: no longer necessary a bitcoin address, can be a Ln invoice. */ - public void setLastCopiedAddress(String address) { - lastCopiedAddress.set(address); + public void setLastCopiedContentFromReceive(String content) { + lastCopiedAddress.set(content); } /** @@ -403,6 +414,20 @@ public Observable watchPendingTaprootCelebration() { return taprootCelebrationPending.asObservable(); } + /** + * Save a flag signalling that the "Welcome to Muun" dialog has been shown/seen. + */ + public void setWelcomeToMuunDialogSeen() { + seenWelcomeToMuunDialogPreference.set(true); + } + + /** + * Get whether the user has seen the "Welcome to Muun" dialog or not. + */ + public Boolean getWelcomeToMuunDialogSeen() { + return seenWelcomeToMuunDialogPreference.get(); + } + /** * Migration to init emergency kit version. */ diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java b/android/apollo/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java index 2f622b90..c52fcf82 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java @@ -1,7 +1,6 @@ package io.muun.apollo.domain; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.data.os.authentication.PinManager; import io.muun.apollo.data.os.secure_storage.SecureStorageProvider; import io.muun.apollo.domain.errors.SecureStorageError; @@ -70,12 +69,16 @@ public synchronized boolean isLockSet() { */ public synchronized boolean tryUnlockWithPin(String pin) { - Crashlytics.logBreadcrumb("ApplicationLockManager#tryUnlockWithPin"); + Timber.i("ApplicationLockManager#tryUnlockWithPin"); try { Preconditions.checkPositive(getRemainingAttempts()); } catch (IllegalArgumentException e) { - throw new WeirdIncorrectAttemptsBugError(getRemainingAttempts(), getMaxAttempts()); + throw new WeirdIncorrectAttemptsBugError( + getRemainingAttempts(), + getMaxAttempts(), + secureStorageProvider.debugSnapshot() + ); } final boolean verified = pinManager.verifyPin(pin); @@ -89,22 +92,11 @@ public synchronized boolean tryUnlockWithPin(String pin) { decrementRemainingAttempts(); } - Crashlytics.logBreadcrumb("ApplicationLockManager#verified: " + verified); + Timber.i("ApplicationLockManager#verified: " + verified); return verified; } - /** - * Attempt to unset the lock with a fingerprint. - */ - public synchronized void tryUnlockWithFingerprint() { - Preconditions.checkPositive(getRemainingAttempts()); - - // Fingerprint should already be validated by the OS. We have nothing to check. - resetRemainingAttempts(); - unsetLock(); - } - /** * Automatically set the application locked state after a delay. Can be canceled (see below). */ @@ -168,14 +160,14 @@ private synchronized void unsetLock() { private synchronized void decrementRemainingAttempts() { - Crashlytics.logBreadcrumb("ApplicationLockManager#decrementRemainingAttempts"); + Timber.i("ApplicationLockManager#decrementRemainingAttempts"); final int incorrectAttempts = Math.min( fetchIncorrectAttempts() + 1, getMaxAttempts() ); - Crashlytics.logBreadcrumb( + Timber.i( "ApplicationLockManager#storeIncorrectAttempts: " + incorrectAttempts ); storeIncorrectAttempts(incorrectAttempts); @@ -194,7 +186,7 @@ private synchronized void storeIncorrectAttempts(int incorrectAttempts) { } private synchronized int fetchIncorrectAttempts() { - Crashlytics.logBreadcrumb("ApplicationLockManager#fetchIncorrectAttempts"); + Timber.i("ApplicationLockManager#fetchIncorrectAttempts"); try { return Encodings.bytesToInt(secureStorageProvider.get(KEY_INCORRECT_ATTEMPTS)); @@ -202,7 +194,7 @@ private synchronized int fetchIncorrectAttempts() { } catch (SecureStorageProvider.SecureStorageNoSuchElementError error) { // Yeah, we shouldn't use exceptions for normal control flow, but for now... // TODO: this whole class needs some serious refactoring - Crashlytics.logBreadcrumb("Resetting incorrect attempts to 0"); + Timber.i("Resetting incorrect attempts to 0"); storeIncorrectAttempts(0); return 0; @@ -217,7 +209,7 @@ private synchronized int fetchIncorrectAttempts() { // force SecureStorageProvider to resolve this data inconsistency. final String ivsInPrefs = (String) error.getMetadata().get("labelsWithIvInPrefs"); - Crashlytics.logBreadcrumb("KeyStoreCorruptedError in fetchIncorrectAttempts"); + Timber.i("KeyStoreCorruptedError in fetchIncorrectAttempts"); if (ivsInPrefs != null && ivsInPrefs.contains(KEY_INCORRECT_ATTEMPTS)) { @@ -236,7 +228,7 @@ private synchronized int fetchIncorrectAttempts() { // Keystore, we try continue execution hoping this is the only piece of data // affected by this data corruption. error.addMetadata("hasBackup", challengePublicKeySel.existsAnyType()); - Crashlytics.logBreadcrumb("bad_padding_exception_workaround"); + Timber.i("bad_padding_exception_workaround"); Timber.e(error, "WORKAROUND for BadPaddingException in fetchIncorrectAttempts"); return 0; diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/ClipboardManager.kt b/android/apollo/src/main/java/io/muun/apollo/domain/ClipboardManager.kt index f4d9df91..eeb50054 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/ClipboardManager.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/ClipboardManager.kt @@ -11,7 +11,7 @@ import javax.inject.Inject */ class ClipboardManager @Inject constructor( private val clipboardProvider: ClipboardProvider, - private val userRepository: UserRepository + private val userRepository: UserRepository, ) { /** @@ -28,6 +28,8 @@ class ClipboardManager @Inject constructor( */ fun copyQrContent(qrContent: String) { copy("Bitcoin address/Ln invoice", qrContent) - userRepository.lastCopiedAddress = qrContent + userRepository.lastCopiedContentFromReceive = qrContent } + + fun watchForPlainText() = clipboardProvider.watchForPlainText() } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/NotificationProcessor.java b/android/apollo/src/main/java/io/muun/apollo/domain/NotificationProcessor.java index e89eed0f..a7fc972a 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/NotificationProcessor.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/NotificationProcessor.java @@ -227,7 +227,8 @@ private Completable handleOperationUpdate(NotificationJson notification, long re message.hash, message.status, nextTransactionSize, - submarineSwap); + submarineSwap + ); return updateOperation .action(operationUpdated) @@ -295,7 +296,7 @@ private Completable handleEventCommunication( // an RTD refresh), the home screen could change before their eyes. To mitigate this, we // preemptively call `fetchRealTimeData` here. fetchRealTimeData.runForced(); - + notificationService.showEventCommunication(message.event); return Completable.complete(); diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/ShowWelcomeToMuunManager.kt b/android/apollo/src/main/java/io/muun/apollo/domain/ShowWelcomeToMuunManager.kt new file mode 100644 index 00000000..be539369 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/ShowWelcomeToMuunManager.kt @@ -0,0 +1,16 @@ +package io.muun.apollo.domain + +import io.muun.apollo.data.preferences.UserRepository +import javax.inject.Inject + +class ShowWelcomeToMuunManager @Inject constructor( + private val userRepository: UserRepository, +) { + + fun getSeen(): Boolean = + userRepository.welcomeToMuunDialogSeen + + fun setSeen() { + userRepository.setWelcomeToMuunDialogSeen() + } +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/LogoutActions.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/LogoutActions.java index d39b4989..1baa678e 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/LogoutActions.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/LogoutActions.java @@ -4,7 +4,6 @@ import io.muun.apollo.data.db.DaoManager; import io.muun.apollo.data.external.NotificationService; import io.muun.apollo.data.fs.LibwalletDataDirectory; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.data.os.secure_storage.SecureStorageProvider; import io.muun.apollo.data.preferences.BaseRepository; import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository; @@ -155,7 +154,7 @@ private boolean isTesting() { } private void destroyWallet() { - Crashlytics.logBreadcrumb("destroyWallet"); + Timber.i("destroyWallet"); taskScheduler.unscheduleAllTasks(); asyncActionStore.resetAllExceptLogout(); @@ -187,7 +186,7 @@ public void clearAllRepositories() { * clearing some repositories on logout (e.g FcmTokenRepository). */ private void clearRepositoriesForLogout() { - for (BaseRepository repository : repositoryRegistry.repositoriesToClear()) { + for (BaseRepository repository : repositoryRegistry.repositoriesToClearOnLogout()) { clearRepository(repository); } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions+Extensions.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions+Extensions.kt new file mode 100644 index 00000000..69dceca7 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions+Extensions.kt @@ -0,0 +1,11 @@ +package io.muun.apollo.domain.action + +import io.muun.common.api.beam.notification.NotificationJson + +fun List.mapIds(): List { + return this.map { it.id } +} + +fun List.asString(): String { + return this.map { it.toString() }.toTypedArray().contentToString() +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions.java index 2a580310..48052012 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/NotificationActions.java @@ -31,6 +31,9 @@ import javax.inject.Named; import javax.inject.Singleton; +import static io.muun.apollo.domain.action.NotificationActions_ExtensionsKt.asString; +import static io.muun.apollo.domain.action.NotificationActions_ExtensionsKt.mapIds; + @Singleton // important public class NotificationActions { @@ -54,7 +57,7 @@ public class NotificationActions { public final AsyncAction0 pullNotificationsAction; - private AppStandbyBucketProvider appStandbyBucketProvider; + private final AppStandbyBucketProvider appStandbyBucketProvider; /** * Constructor. @@ -90,17 +93,16 @@ public synchronized void onNotificationReport(NotificationReport report) { } /** - * Pull the latest notifications from Hosuton. + * Pull the latest notifications from Houston. */ public Observable pullNotifications() { return Observable.defer(() -> { - Timber.d("[Notifications] Pulling..."); - - final long lastProccessedId = notificationRepository.getLastProcessedId(); + final long lastProcessedId = notificationRepository.getLastProcessedId(); + Timber.i("[Notifications] Pulling... LastProcessedId: " + lastProcessedId); - return houstonClient.fetchNotificationReportAfter(lastProccessedId) + return houstonClient.fetchNotificationReportAfter(lastProcessedId) .map(report -> { onNotificationReport(report); return null; @@ -147,6 +149,8 @@ private Completable processNotificationList(final List notific return Completable.defer(() -> { final long lastIdBefore = notificationRepository.getLastProcessedId(); + Timber.i("[Notifications] Processing List: " + asString(mapIds(notifications))); + return Observable.from(notifications) .compose(forEach(this::processNotification)) .lastOrDefault(null) @@ -178,11 +182,13 @@ private Completable processNotification(NotificationJson notification) { return Completable.defer(() -> { final String messageType = notification.messageType; - Timber.d("[Notifications] Processing " + messageType); + final String bucket = appStandbyBucketProvider.current().toString(); + final Long id = notification.id; + Timber.i("[Notifications] Processing (" + id + ") " + messageType + " - " + bucket); final long lastProcessedId = notificationRepository.getLastProcessedId(); - if (notification.id <= lastProcessedId) { + if (id <= lastProcessedId) { return Completable.complete(); // already processed! } @@ -195,6 +201,7 @@ private Completable processNotification(NotificationJson notification) { .onErrorComplete(cause -> { notificationRepository.increaseProcessingFailures(); + logBreadcrumb(id, messageType, processingFailures, cause); Timber.e(NotificationProcessingError.fromCause(notification, cause)); if (processingFailures > 3) { @@ -202,6 +209,7 @@ private Completable processNotification(NotificationJson notification) { return true; } + //noinspection RedundantIfStatement if (messageType.equals(FulfillIncomingSwapMessage.SPEC.messageType)) { // We don't allow skipping fulfills return false; @@ -209,9 +217,9 @@ private Completable processNotification(NotificationJson notification) { return true; // skip notification, log the error }) - .doOnCompleted(() -> { - notificationRepository.setLastProcessedId(notification.id); - }); + .doOnCompleted(() -> + notificationRepository.setLastProcessedId(id) + ); }); } @@ -258,5 +266,10 @@ private Transformer forEach(Func1 createTask) { ); } - + private void logBreadcrumb(Long id, String msgType, long failures, Throwable cause) { + final String e = cause.getClass().getSimpleName() + ":" + cause.getLocalizedMessage(); + Timber.i( + "[Notifications] Error: (" + id + ") " + msgType + " - " + failures + " - " + e + ); + } } 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 4d8c53db..92ee2cad 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 @@ -35,6 +35,7 @@ import io.muun.apollo.domain.action.session.rc_only.LogInWithRcAction; import io.muun.apollo.domain.action.user.EmailLinkAction; import io.muun.apollo.domain.action.user.UpdateProfilePictureAction; +import io.muun.apollo.domain.debug.DebugExecutable; import dagger.Component; @@ -123,4 +124,6 @@ public interface ActionComponent { StartRecoveryCodeSetupAction startRecoveryCodeSetupAction(); UpdateContactsPermissionStateAction updateContactsPermissionStateAction(); + + DebugExecutable debugExecutable(); } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt index 20cbfc5e..5eecd73f 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt @@ -1,6 +1,5 @@ package io.muun.apollo.domain.action.ek -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.os.execution.ExecutionTransformerFactory import io.muun.apollo.data.preferences.KeysRepository import io.muun.apollo.data.preferences.UserRepository @@ -10,7 +9,7 @@ import io.muun.apollo.domain.model.EmergencyKitExport import io.muun.apollo.domain.model.GeneratedEmergencyKit import rx.Observable import timber.log.Timber -import java.util.* +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -46,9 +45,7 @@ class RenderEmergencyKitAction @Inject constructor( reportEmergencyKitExported.action(export) .subscribeOn(transformerFactory.backgroundScheduler) .subscribe({}, { error -> - Crashlytics.logBreadcrumb( - "Error while reportEmergencyKitExported" - ) + Timber.i("Error while reportEmergencyKitExported") Timber.e(error) }) } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/fcm/UpdateFcmTokenAction.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/fcm/UpdateFcmTokenAction.java index 7fad931e..8cfd7e6b 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/fcm/UpdateFcmTokenAction.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/fcm/UpdateFcmTokenAction.java @@ -1,6 +1,5 @@ package io.muun.apollo.domain.action.fcm; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.data.net.HoustonClient; import io.muun.apollo.data.preferences.AuthRepository; import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository; @@ -50,7 +49,7 @@ public Observable action(String newFcmToken) { // If anyone of these is true, we can't perform an http request to Houston (NOT_AUTHORIZED) if (!hasValidSession || !hasJwt) { - Crashlytics.logBreadcrumb("UpdateFcmToken: NOT_AUTHORIZED"); + Timber.i("UpdateFcmToken: NOT_AUTHORIZED"); // As IDE might tell you, !hasJwt will always be true at this point. We're leaving it: // - for readability @@ -62,7 +61,7 @@ public Observable action(String newFcmToken) { // Integrity check. May sound silly but its our canary for when things go wrong with // a logout (our logout logic has become a bit cumbersome) or a local storage wipe. if (hasValidSessionButNoJwt || hasJwtButInvalidSession) { - Crashlytics.logBreadcrumb( + Timber.i( String.format( "LocalStorageIntegrityError (%s,%s)", hasValidSessionButNoJwt, @@ -82,7 +81,7 @@ public Observable action(String newFcmToken) { return Observable.just(null); } - Crashlytics.logBreadcrumb("Updating FCM token"); + Timber.i("Updating FCM token"); return houstonClient.updateFcmToken(newFcmToken) .flatMap(ignore -> notificationActions.pullNotifications()); diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/integrity/GooglePlayIntegrity.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/integrity/GooglePlayIntegrity.kt index 588181b8..c19eee5f 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/integrity/GooglePlayIntegrity.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/integrity/GooglePlayIntegrity.kt @@ -6,7 +6,6 @@ import com.google.android.play.core.integrity.IntegrityTokenRequest import com.google.android.play.core.integrity.IntegrityTokenResponse import com.google.android.play.core.integrity.model.IntegrityErrorCode import com.google.firebase.FirebaseApp -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.domain.errors.PlayIntegrityError import io.muun.apollo.domain.model.PlayIntegrityToken import rx.Observable @@ -83,7 +82,7 @@ class GooglePlayIntegrity @Inject constructor(private val context: Context) { ) { val integrityToken = response.token() - Crashlytics.logBreadcrumb("IntegrityTokenFetched: $integrityToken") + Timber.i("IntegrityTokenFetched: $integrityToken") observer.onNext(PlayIntegrityToken(integrityToken)) observer.onCompleted() @@ -93,8 +92,8 @@ class GooglePlayIntegrity @Inject constructor(private val context: Context) { val playIntegrityError = buildPlayIntegrityError(e) - Crashlytics.logBreadcrumb("IntegrityTokenError: ${playIntegrityError.getName()}") - Crashlytics.logBreadcrumb("IntegrityTokenError: ${playIntegrityError.getText()}") + Timber.i("IntegrityTokenError: ${playIntegrityError.getName()}") + Timber.i("IntegrityTokenError: ${playIntegrityError.getText()}") Timber.e(playIntegrityError) @@ -103,7 +102,7 @@ class GooglePlayIntegrity @Inject constructor(private val context: Context) { } private fun handleIntegrityTokenCanceled(observer: BehaviorSubject) { - Crashlytics.logBreadcrumb("IntegrityTokenCancelled") + Timber.i("IntegrityTokenCancelled") Timber.e(PlayIntegrityError()) observer.onNext(PlayIntegrityToken(null, "CANCELLED")) diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/CreateOperationAction.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/CreateOperationAction.java index 2f98a3f8..d339aebf 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/CreateOperationAction.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/CreateOperationAction.java @@ -58,11 +58,14 @@ public CreateOperationAction(final TransactionSizeRepository transactionSizeRepo this.verifyFulfillable = verifyFulfillable; } + /** + * Create/Store a new Operation in the local database, and update the transaction size vector. + */ public Observable action(Operation operation, NextTransactionSize nextTransactionSize) { return saveOperation(operation) .map(savedOperation -> { - Timber.d("Updating next transaction size estimation"); + Timber.i("Updating next transaction size estimation"); transactionSizeRepository.setTransactionSize(nextTransactionSize); if (savedOperation.direction == OperationDirection.INCOMING) { @@ -75,7 +78,11 @@ public Observable action(Operation operation, final Observable continuation = Observable.just(savedOperation); if (savedOperation.incomingSwap != null) { + Timber.i("Incoming Swap Fulfillable: verifying"); return verifyFulfillable.action(savedOperation.incomingSwap) + .doOnCompleted(() -> { + Timber.i("Incoming Swap Fulfillable: success"); + }) .andThen(continuation); } else { return continuation; diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/DetectAppUpdateAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/DetectAppUpdateAction.kt index 59162e36..89e19231 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/DetectAppUpdateAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/DetectAppUpdateAction.kt @@ -1,7 +1,6 @@ package io.muun.apollo.domain.action.session import io.muun.apollo.data.external.Globals -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.preferences.AppVersionRepository import timber.log.Timber import javax.inject.Inject @@ -11,7 +10,7 @@ class DetectAppUpdateAction @Inject constructor(private val repo: AppVersionRepo fun run() { val currentVersion = Globals.INSTANCE.versionCode if (currentVersion > repo.getVersion()) { - Crashlytics.logBreadcrumb("App update: ${repo.getVersion()} -> $currentVersion") + Timber.i("App update: ${repo.getVersion()} -> $currentVersion") repo.update(currentVersion) } else if (currentVersion < repo.getVersion()) { diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt index e6058b24..309d1351 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt @@ -1,6 +1,5 @@ package io.muun.apollo.domain.action.session.rc_only -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository import io.muun.apollo.data.preferences.PlayIntegrityNonceRepository @@ -14,6 +13,7 @@ import io.muun.common.api.KeySet import io.muun.common.model.challenge.Challenge import libwallet.Libwallet import rx.Observable +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -55,8 +55,8 @@ class LogInWithRcAction @Inject constructor( private fun createRcLoginSession(recoveryCode: String): Observable { val pubKeyHex = Libwallet.recoveryCodeToKey(recoveryCode, null).pubKeyHex() val bigQueryPseudoId = firebaseInstallationIdRepo.getBigQueryPseudoId() - Crashlytics.logBreadcrumb("Rc Login: $pubKeyHex") - Crashlytics.logBreadcrumb("Rc Login: $bigQueryPseudoId") + Timber.i("Rc Login: $pubKeyHex") + Timber.i("Rc Login: $bigQueryPseudoId") return getFcmToken.action() .flatMap { fcmToken -> houstonClient.createRcLoginSession( 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 ee5ccae8..fb6b34aa 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 @@ -437,12 +437,12 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( LNURL_NO_ROUTE, LNURL_COUNTRY_NOT_SUPPORTED, LNURL_ALREADY_USED, - GENERIC, // Use to report all erros handled by BasePresenter's handleError() + GENERIC, // Use to report all errors handled by BasePresenter's handleError() RC_SETUP_START_CONNECTION_ERROR, RC_SETUP_FINISH_CONNECTION_ERROR, RC_STALE_ERROR, - RC_CREDENTIALS_DONT_MATCH_ERROR - + RC_CREDENTIALS_DONT_MATCH_ERROR, + CRASHLYTICS } class E_ERROR(val type: ERROR_TYPE, vararg extras: Any) : AnalyticsEvent( @@ -493,6 +493,10 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( PDF_EXPORTED } + class E_CRASHLYTICS_ERROR(value: String) : AnalyticsEvent( + listOf("title" to value) + ) + class E_BREADCRUMB(value: String) : AnalyticsEvent( listOf("crumb" to value) ) diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt b/android/apollo/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt new file mode 100644 index 00000000..ee442877 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt @@ -0,0 +1,84 @@ +package io.muun.apollo.domain.debug + +import io.muun.apollo.data.debug.LappClient +import io.muun.apollo.data.debug.LappClientError +import io.muun.apollo.data.os.execution.ExecutionTransformerFactory +import io.muun.apollo.domain.action.address.CreateAddressAction +import io.muun.apollo.domain.action.incoming_swap.GenerateInvoiceAction +import io.muun.apollo.domain.errors.debug.DebugExecutableError +import io.muun.apollo.domain.selector.UserPreferencesSelector +import io.muun.common.crypto.hd.MuunAddress +import rx.Observable +import javax.inject.Inject +import javax.inject.Singleton + +/** + * TODO: The idea is for this class to actually become an interface and have multiple + * DebugExecutable impls each for its own functionality. + */ +@Singleton +class DebugExecutable @Inject constructor( + private val createAddress: CreateAddressAction, + private val generateInvoice: GenerateInvoiceAction, + private val userPreferencesSel: UserPreferencesSelector, + private val transformerFactory: ExecutionTransformerFactory, +) { + + private val lapp = LappClient() + + fun fundWalletOnChain(): Observable = Observable.defer { + val segwitAddress: MuunAddress = createAddress.actionNow().segwit + lapp.receiveBtc(0.4, segwitAddress.address) + + Observable.just(null) + }.compose(transformerFactory.getAsyncExecutor()) + .compose(errorMapper()) + + fun fundWalletOffChain(): Observable = Observable.defer { + val amountInSats = 11000L + val invoice: String = generateInvoice.actionNow(amountInSats) + val turboChannels: Boolean = !userPreferencesSel.get().strictMode + lapp.receiveBtcViaLN(invoice, amountInSats, turboChannels) + + Observable.just(null) + }.compose(transformerFactory.getAsyncExecutor()) + .compose(errorMapper()) + + fun generateBlock(): Observable = Observable.defer { + lapp.generateBlocks(1) + + Observable.just(null) + }.compose(transformerFactory.getAsyncExecutor()) + + fun dropLastTxFromMempool(): Observable = Observable.defer { + lapp.dropLastTxFromMempool() + + Observable.just(null) + }.compose(transformerFactory.getAsyncExecutor()) + .compose(errorMapper()) + + fun dropTx(txId: String): Observable = Observable.defer { + lapp.dropTx(txId) + + Observable.just(null) + }.compose(transformerFactory.getAsyncExecutor()) + .compose(errorMapper()) + + fun undropTx(txId: String): Observable = Observable.defer { + lapp.undropTx(txId) + + Observable.just(null) + }.compose(transformerFactory.getAsyncExecutor()) + .compose(errorMapper()) + + private fun errorMapper() = { observable: Observable -> + observable.onErrorResumeNext { error: Throwable -> + if (error is LappClientError) { + Observable.error(DebugExecutableError(error.message)) + } else { + Observable.error(error) + } + } + } + +} diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/WeirdIncorrectAttemptsBugError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/WeirdIncorrectAttemptsBugError.kt index 965eeefe..dd87a20c 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/errors/WeirdIncorrectAttemptsBugError.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/WeirdIncorrectAttemptsBugError.kt @@ -1,16 +1,20 @@ package io.muun.apollo.domain.errors +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider import io.muun.common.exception.PotentialBug class WeirdIncorrectAttemptsBugError( remainingAttempts: Int, maxAttempts: Int, -) : MuunError( - "IncorrectAttempts, in secure storage, was found to be higher or equal than max attempts" + debugSnapshot: SecureStorageProvider.DebugSnapshot, +) : SecureStorageError( + debugSnapshot ), PotentialBug { init { + metadata["message"] = + "IncorrectAttempts in secure storage, was found to be higher or equal than max attempts" metadata["remainingAttempts"] = remainingAttempts metadata["maxAttempts"] = maxAttempts metadata["incorrectAttempts"] = maxAttempts - remainingAttempts diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/debug/DebugExecutableError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/debug/DebugExecutableError.kt new file mode 100644 index 00000000..9ffc47c1 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/debug/DebugExecutableError.kt @@ -0,0 +1,3 @@ +package io.muun.apollo.domain.errors.debug + +class DebugExecutableError(override val message: String) : RuntimeException() \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/selector/ClipboardUriSelector.kt b/android/apollo/src/main/java/io/muun/apollo/domain/selector/ClipboardUriSelector.kt index be8d1771..805d4b85 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/selector/ClipboardUriSelector.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/selector/ClipboardUriSelector.kt @@ -16,14 +16,18 @@ import javax.inject.Inject class ClipboardUriSelector @Inject constructor( private val clipboardProvider: ClipboardProvider, private val userRepository: UserRepository, - private val transformerFactory: ExecutionTransformerFactory + private val transformerFactory: ExecutionTransformerFactory, ) { + @Deprecated( + "Starting Android 12 (api 31) special care needs to be taken when accessing Clipboard." + + "Constantly checking the clipboard is no longer an accepted/ux-friendly practice." + ) fun watch(): Observable = clipboardProvider.watchPrimaryClip() .compose(transformerFactory.getObservableReverseAsyncExecutor()) // sub on main required .map { - if (it == userRepository.lastCopiedAddress) { + if (it == userRepository.lastCopiedContentFromReceive) { null // don't show the user the address or invoice she just generated } else try { @@ -34,6 +38,19 @@ class ClipboardUriSelector @Inject constructor( } } - fun get() = - watch().toBlocking().first() + /** + * Starting Android 12 (api 31) special care needs to be taken when accessing Clipboard. + * Constantly checking the clipboard is no longer an accepted/ux-friendly practice (e.g OS shows + * a toast message with the legend 'APP pasted from your clipboard'). Callers are responsible of + * calling this method in a UX-friendly manner. + */ + fun getText(): String = + clipboardProvider.paste()?.trim() ?: "" + + /** + * Checks whether a certain content is exactly equal to what the user last copied to the + * clipboard in our Receive screen. + */ + fun isLastCopiedFromReceive(content: String): Boolean = + content == userRepository.lastCopiedContentFromReceive } \ No newline at end of file diff --git a/android/apollo/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java b/android/apollo/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java index 317eca5e..e22ec5ac 100644 --- a/android/apollo/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java +++ b/android/apollo/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java @@ -115,12 +115,6 @@ public void doesNotDecrementAttemptsWhenUnrecoverable() { assertThat(lockManager.getRemainingAttempts()).isEqualTo(maxAttempts); } - @Test - public void unlockWithAnyFingerprint() { - lockManager.tryUnlockWithFingerprint(); - assertThat(lockManager.isLockSet()).isFalse(); - } - @Test @Ignore("flaky") public void autoSetLock() throws InterruptedException { @@ -151,9 +145,8 @@ public void resetAttemptsAfterUnlock() { lockManager.tryUnlockWithPin(CORRECT_PIN); assertThat(lockManager.getRemainingAttempts()).isEqualTo(lockManager.getMaxAttempts()); - burnRemainingAttempts(1); - lockManager.tryUnlockWithFingerprint(); - assertThat(lockManager.getRemainingAttempts()).isEqualTo(lockManager.getMaxAttempts()); + + // TODO attempts should be reset on biometric successful unlock } private void burnRemainingAttempts(int amount) { diff --git a/android/apollo/src/test/java/io/muun/apollo/domain/action/user/UpdateUserPreferencesActionTest.kt b/android/apollo/src/test/java/io/muun/apollo/domain/action/user/UpdateUserPreferencesActionTest.kt index a9ee41c2..fef0385b 100644 --- a/android/apollo/src/test/java/io/muun/apollo/domain/action/user/UpdateUserPreferencesActionTest.kt +++ b/android/apollo/src/test/java/io/muun/apollo/domain/action/user/UpdateUserPreferencesActionTest.kt @@ -31,16 +31,18 @@ class UpdateUserPreferencesActionTest : BaseTest() { @Before fun before() { - doReturn(Observable.just( - UserPreferences( - strictMode = false, - seenNewHome = false, - seenLnurlFirstTime = false, - defaultAddressType = "segwit", - skippedEmailSetup = false, - receivePreference = ReceiveFormatPreference.ONCHAIN + doReturn( + Observable.just( + UserPreferences( + strictMode = false, + seenNewHome = false, + seenLnurlFirstTime = false, + defaultAddressType = "segwit", + skippedEmailSetup = false, + receivePreference = ReceiveFormatPreference.ONCHAIN, + ) ) - )).whenever(repository).watch() + ).whenever(repository).watch() } @Test diff --git a/android/apolloui/build.gradle b/android/apolloui/build.gradle index 09f0a312..d3c6baa0 100644 --- a/android/apolloui/build.gradle +++ b/android/apolloui/build.gradle @@ -87,8 +87,8 @@ android { applicationId "io.muun.apollo" minSdkVersion 19 targetSdkVersion 33 - versionCode 1100 - versionName "51" + versionCode 1101 + versionName "51.1" // 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/ @@ -404,7 +404,7 @@ dependencies { androidTestImplementation 'org.mockito:mockito-android:3.10.0' androidTestImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0" - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' // UiAutomator Testing androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/BaseInstrumentationTest.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/BaseInstrumentationTest.kt index 5a646101..102bf9bf 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/BaseInstrumentationTest.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/BaseInstrumentationTest.kt @@ -3,11 +3,14 @@ package io.muun.apollo.presentation import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Handler +import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until +import com.bumptech.glide.Glide import io.muun.apollo.data.preferences.AuthRepository import io.muun.apollo.data.preferences.UserRepository import io.muun.apollo.domain.SignupDraftManager @@ -18,12 +21,19 @@ import io.muun.apollo.presentation.app.ApolloApplication import io.muun.apollo.presentation.app.Navigator import io.muun.apollo.presentation.ui.launcher.LauncherActivity import io.muun.apollo.utils.AutoFlows +import io.muun.apollo.utils.SystemCommand import io.muun.apollo.utils.WithMuunInstrumentationHelpers +import org.junit.After import org.junit.Before import org.junit.Rule +import org.junit.rules.TestName open class BaseInstrumentationTest : WithMuunInstrumentationHelpers { + @JvmField + @Rule + var name: TestName = TestName() + @JvmField @Rule var activityRule = ActivityTestRule(LauncherActivity::class.java) @@ -54,6 +64,9 @@ open class BaseInstrumentationTest : WithMuunInstrumentationHelpers { val intent = Intent(Intent.ACTION_MAIN) intent.addCategory(Intent.CATEGORY_HOME) + Log.i("test", "=========================================================") + Log.i("test", name.methodName + "#setUp") + testContext = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext @@ -77,6 +90,13 @@ open class BaseInstrumentationTest : WithMuunInstrumentationHelpers { autoFlows = AutoFlows(device, context) clearData() + + SystemCommand.disableSoftKeyboard() + } + + @After + fun cleanUp() { + SystemCommand.enableSoftKeyboard() } protected fun sniffActivationCode(): String? { @@ -89,8 +109,17 @@ open class BaseInstrumentationTest : WithMuunInstrumentationHelpers { // with test framework. if (shouldClearData()) { + Log.i("test", this.javaClass.simpleName + "#clearData") + logoutActions.uncheckedDestroyWalletForUiTests() + val mainHandler = Handler(context.mainLooper) + mainHandler.post { + // Avoid Glide Bitmaps leaking memory. This may produce a tiny performance hit due + // to having to reload them for every time but is negligible. + Glide.get(context).clearMemory() + } + // This is necessary if a previous test hung or errored on signup/sign-in initial sync // TODO: we may need to do this for other async actions if we want to avoid problems // with async actions not running again when supposed to. Maybe a way of resetting EVERY diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/IncomingSwapTests.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/IncomingSwapTests.kt index 5e9bcc9a..b96f9bd6 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/IncomingSwapTests.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/IncomingSwapTests.kt @@ -2,7 +2,7 @@ package io.muun.apollo.presentation import androidx.test.ext.junit.runners.AndroidJUnit4 import io.muun.apollo.R -import io.muun.apollo.presentation.ui.debug.LappClient +import io.muun.apollo.data.debug.LappClient import io.muun.apollo.utils.RandomUser import io.muun.common.utils.BitcoinUtils import org.junit.Test diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LnUrlWithdrawTests.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LnUrlWithdrawTests.kt index 8083697c..c34e313a 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LnUrlWithdrawTests.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LnUrlWithdrawTests.kt @@ -3,56 +3,54 @@ package io.muun.apollo.presentation import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.muun.apollo.R -import io.muun.apollo.presentation.ui.debug.LappClient +import io.muun.apollo.data.debug.LappClient import io.muun.apollo.utils.MuunTexts import io.muun.common.utils.BitcoinUtils import org.junit.Test import org.junit.runner.RunWith +import javax.money.MonetaryAmount @RunWith(AndroidJUnit4::class) open class LnUrlWithdrawTests : BaseInstrumentationTest() { + // For now the withdraw amount is fixed + private val lnurlWithdrawAmount: MonetaryAmount = BitcoinUtils.satoshisToBitcoins(3000) + @Test fun test_01_a_user_can_withdraw_using_lnurl_via_receive() { autoFlows.signUp() - // For now the withdraw amount is fixed - val amountToWithdraw = BitcoinUtils.satoshisToBitcoins(3000) val balanceBefore = homeScreen.balanceInBtc autoFlows.lnUrlWithdrawViaReceive() // We should be at home by now - homeScreen.checkBalanceCloseTo(balanceBefore.add(amountToWithdraw)) + homeScreen.checkBalanceCloseTo(balanceBefore.add(lnurlWithdrawAmount)) } @Test fun test_02_a_user_can_withdraw_using_lnurl_via_send() { autoFlows.signUp() - // For now the withdraw amount is fixed - val amountToWithdraw = BitcoinUtils.satoshisToBitcoins(3000) val balanceBefore = homeScreen.balanceInBtc autoFlows.lnUrlWithdrawViaSend() // We should be at home by now - homeScreen.checkBalanceCloseTo(balanceBefore.add(amountToWithdraw)) + homeScreen.checkBalanceCloseTo(balanceBefore.add(lnurlWithdrawAmount)) } @Test fun test_03_a_user_can_withdraw_using_lnurl_when_taking_too_long() { autoFlows.signUp() - // For now the withdraw amount is fixed - val amountToWithdraw = BitcoinUtils.satoshisToBitcoins(3000) val balanceBefore = homeScreen.balanceInBtc autoFlows.lnUrlWithdrawViaSend(LappClient.LnUrlVariant.SLOW) // We should be at home by now homeScreen.checkBalanceCloseTo(balanceBefore) // balance should not change immediately - homeScreen.waitUntilBalanceEquals(balanceBefore.add(amountToWithdraw)) + homeScreen.waitUntilBalanceEquals(balanceBefore.add(lnurlWithdrawAmount)) } @Test @@ -170,4 +168,18 @@ open class LnUrlWithdrawTests : BaseInstrumentationTest() { // We should be at home by now homeScreen.checkBalanceCloseTo(balanceBefore) // balance should not change } + + @Test + fun test_10_a_user_can_withdraw_using_lnurl_via_send_via_manual_input() { + autoFlows.signUp() + + val balanceBefore = homeScreen.balanceInBtc + + autoFlows.lnUrlWithdrawViaSend { lnurl -> + autoFlows.startOperationManualInputTo(lnurl) + } + + // We should be at home by now + homeScreen.checkBalanceCloseTo(balanceBefore.add(lnurlWithdrawAmount)) + } } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LoginAndSignUpTests.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LoginAndSignUpTests.kt index 8e62d611..a65ec396 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LoginAndSignUpTests.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/LoginAndSignUpTests.kt @@ -78,7 +78,7 @@ open class LoginAndSignUpTests : BaseInstrumentationTest() { // Create Wallet (U.U) signInScreen.startSignup() - label(R.string.choose_your_pin).waitForExists(3000) + label(R.string.choose_your_pin).assertExists() signInScreen.back() checkToastDisplayed(R.string.pin_error_on_setup_cancel) @@ -125,7 +125,7 @@ open class LoginAndSignUpTests : BaseInstrumentationTest() { // It appears we have some flakyness here. Sometimes we arrive at Enter RC screen with // input already focused which means the soft keyboard is active and the first back gets // "eaten" to dismiss the keyboard. - id(R.id.signup_forgot_password_continue).waitForExists(2000) + id(R.id.signup_forgot_password_continue).assertExists() Espresso.closeSoftKeyboard() signInScreen.back() diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/NewOperationTests.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/NewOperationTests.kt index dca7784d..c99078a2 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/NewOperationTests.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/NewOperationTests.kt @@ -1,10 +1,14 @@ package io.muun.apollo.presentation import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.muun.apollo.presentation.ui.debug.LappClient +import io.muun.apollo.R +import io.muun.apollo.data.debug.LappClient +import io.muun.apollo.data.external.Globals import io.muun.apollo.presentation.ui.helper.isBtc import io.muun.apollo.utils.RandomUser +import io.muun.common.model.DebtType import io.muun.common.utils.BitcoinUtils +import io.muun.common.utils.LnInvoice import org.assertj.core.api.Assertions.assertThat import org.javamoney.moneta.Money import org.junit.Ignore @@ -94,7 +98,7 @@ open class NewOperationTests : BaseInstrumentationTest() { autoFlows.receiveMoneyFromNetwork(Money.of(0.1, "BTC")) // This amount is "brittle/fickle" as it depends on service configs - // Must be greate than MAX_DEBT_PER_USER but lower than AMOUNT_FOR_ZERO_CONFS_IN_USD + // Must be greater than MAX_DEBT_PER_USER but lower than AMOUNT_FOR_ZERO_CONFS_IN_USD val amountThatWillTriggerA0ConfSwapWithoutDebt = 50000 autoFlows.newSubmarineSwap(amountThatWillTriggerA0ConfSwapWithoutDebt) @@ -140,7 +144,7 @@ open class NewOperationTests : BaseInstrumentationTest() { // This amount is "brittle/fickle" as it depends on service configs val amountThatWillTriggerALendSwap = 10_000 // Should be < MAX_USER_DEBT - autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap) + autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap, DebtType.LEND) } @Test @@ -153,13 +157,13 @@ open class NewOperationTests : BaseInstrumentationTest() { // This amount is "brittle/fickle" as it depends on service configs val amountThatWillTriggerALendSwap = 10_000 // Should be < MAX_USER_DEBT - autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap) + autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap, DebtType.LEND) // Let's collect money from user, via a COLLECT SWAP // This amount is "brittle/fickle" as it depends on service configs val amountThatWillTriggerACollectSwap = 20_000 // To be sure that go over MAX_USER_DEBT - autoFlows.newSubmarineSwap(amountThatWillTriggerACollectSwap) + autoFlows.newSubmarineSwap(amountThatWillTriggerACollectSwap, DebtType.COLLECT) } @Test @@ -172,7 +176,7 @@ open class NewOperationTests : BaseInstrumentationTest() { // This amount is "brittle/fickle" as it depends on service configs val amountThatWillTriggerALendSwap = 200 // Should be < MAX_USER_DEBT - autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap) + autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap, DebtType.LEND) // Oops. We need to default money from the user debt. Default TX @@ -191,7 +195,7 @@ open class NewOperationTests : BaseInstrumentationTest() { // This amount is "brittle/fickle" as it depends on service configs val amountThatWillTriggerALendSwap = 200 // Should be < MAX_USER_DEBT - autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap) + autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap, DebtType.LEND) // 2. Let's receive via LN using that debt @@ -261,7 +265,7 @@ open class NewOperationTests : BaseInstrumentationTest() { // This amount is "brittle/fickle" as it depends on service configs val amountThatWillTriggerALendSwap = 200 // Should be < MAX_USER_DEBT - autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap) + autoFlows.newSubmarineSwap(amountThatWillTriggerALendSwap, DebtType.LEND) // 2. Let's receive via LN using that debt @@ -386,4 +390,37 @@ open class NewOperationTests : BaseInstrumentationTest() { homeScreen.goToOperationDetail(description, isPending = true) } } + + @Test + fun test_23_user_can_make_cyclic_payment() { + autoFlows.signUp() + + autoFlows.receiveMoneyFromNetwork(Money.of(0.0102, "BTC")) + + userCanMakeAOnchainCyclePayment() + + userCanNotMakeAOffChainCyclePayment() + } + + private fun userCanMakeAOnchainCyclePayment() { + val receivingAddress = autoFlows.getOwnAddress() + val moneyToSend = Money.of(0.0001, "BTC") + val description = "This is a note " + System.currentTimeMillis() + + autoFlows.newOperation(moneyToSend, description) { + autoFlows.startOperationManualInputTo(receivingAddress) + } + autoFlows.settleOperation(description) + } + + private fun userCanNotMakeAOffChainCyclePayment() { + val ownInvoice = autoFlows.getOwnInvoice() + val lnInvoice = LnInvoice.decode(Globals.INSTANCE.network, ownInvoice) + + autoFlows.startOperationManualInputTo(lnInvoice.original) + + // We don't allow cyclic ln payments yet + label(R.string.error_op_cyclical_swap_title).assertExists() + label(R.string.error_op_cyclical_swap_desc).assertExists() + } } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/P2PSetupTests.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/P2PSetupTests.kt index 2468a403..907eb59a 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/P2PSetupTests.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/presentation/P2PSetupTests.kt @@ -23,18 +23,18 @@ open class P2PSetupTests : BaseInstrumentationTest() { autoFlows.signUp() autoFlows.setupP2P( - phoneNumber = user.phoneNumber, - firstName = user.firstName, - lastName = user.lastName + phoneNumber = user.phoneNumber, + firstName = user.firstName, + lastName = user.lastName ) homeScreen.goToSettings() assertThat(id(R.id.settings_username).text) - .isEqualTo(user.fullName) + .isEqualTo(user.fullName) assertThat(settingsItemContent(R.id.settings_phone_number).text) - .isEqualTo(user.phoneNumber.toE164String()) + .isEqualTo(user.phoneNumber.toE164String()) device.pressBack() } @@ -49,8 +49,7 @@ open class P2PSetupTests : BaseInstrumentationTest() { homeScreen.goToSend() // Expect the Muun contact to appear: - label(contact.fullName) - .waitForExists(15000) + label(contact.fullName).await(15000) id(R.id.header) // Check toolbar is showed, but... device.pressBack() // Still use device back btn, targeting toolbar back btn is sketchy diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/AutoFlows.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/AutoFlows.kt index d2bfaf9e..ef2a472b 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/AutoFlows.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/AutoFlows.kt @@ -4,11 +4,14 @@ import android.content.Context import android.os.SystemClock import androidx.test.uiautomator.UiDevice import io.muun.apollo.R +import io.muun.apollo.data.debug.LappClient import io.muun.apollo.data.external.Gen import io.muun.apollo.domain.model.user.UserPhoneNumber -import io.muun.apollo.presentation.ui.debug.LappClient import io.muun.apollo.presentation.ui.helper.isBtc +import io.muun.apollo.presentation.ui.utils.OS +import io.muun.apollo.presentation.ui.utils.UiUtils import io.muun.apollo.utils.WithMuunInstrumentationHelpers.Companion.balanceNotEqualsErrorMessage +import io.muun.apollo.utils.screens.ReceiveScreen import io.muun.common.model.DebtType import io.muun.common.utils.BitcoinUtils import io.muun.common.utils.LnInvoice @@ -166,7 +169,7 @@ class AutoFlows( fun checkCannotDeleteWallet() { goToSettingsAndClickDeleteWallet() - normalizedLabel(R.string.settings_delete_wallet_explanation_title).waitForExists(5_000) + label(R.string.settings_delete_wallet_explanation_title).await() normalizedLabel(R.string.settings_delete_wallet_explanation_action).click() backToHome() @@ -197,12 +200,6 @@ class AutoFlows( private fun receiveMoneyFromLN(amountInSat: Long, turboChannel: Boolean, amountFixed: Boolean) { val prevBalance = homeScreen.balanceInBtc - homeScreen.goToReceive() - - if (amountFixed) { - receiveScreen.addInvoiceAmount(amountInSat) - } - // For fixed amount invoices, amount MUST NOT be specified, otherwise an error occurs. val amountToReceive = if (amountFixed) { null @@ -210,14 +207,9 @@ class AutoFlows( amountInSat } - LappClient().receiveBtcViaLN(receiveScreen.invoice, amountToReceive, turboChannel) - - // If we return right now, we'll have to wait for the FCM notification to know we received - // the money. If we give Syncer some time to see the transaction, the notification will be - // waiting for us when we reach Home (and pullNotifications): - SystemClock.sleep(1800) + val invoice = getOwnInvoice(amountInSat) - device.pressBack() + LappClient().receiveBtcViaLN(invoice, amountToReceive, turboChannel) // Wait for balance to be updated: val amount = BitcoinUtils.satoshisToBitcoins(amountInSat) @@ -228,6 +220,18 @@ class AutoFlows( } } + fun getOwnInvoice(amountInSat: Long? = null): String { + homeScreen.goToReceive() + + if (amountInSat != null) { + receiveScreen.addInvoiceAmount(amountInSat) + } + + val invoice = receiveScreen.invoice + device.pressBack() // Back to Home + return invoice + } + fun receiveMoneyFromNetwork(amount: Money) = try { tryReceiveMoneyFromNetwork(amount) } catch (e: AssertionError) { @@ -236,8 +240,9 @@ class AutoFlows( LappClient().generateBlocks(30) // we don't want to need this again soon Thread.sleep(2000) tryReceiveMoneyFromNetwork(amount) + } else { + throw e } - throw e } private fun tryReceiveMoneyFromNetwork(amount: Money) { @@ -245,23 +250,22 @@ class AutoFlows( val balanceAfter = expectedBalance.add(amount) // Generate an address: - homeScreen.goToReceive() - val address = receiveScreen.address + val address = getOwnAddress() // Hit RegTest to receive money from the network: LappClient().receiveBtc(amount.number.toDouble(), address) - // If we return right now, we'll have to wait for the FCM notification to know we received - // the money. If we give Syncer some time to see the transaction, the notification will be - // waiting for us when we reach Home (and pullNotifications): - SystemClock.sleep(1800) - - device.pressBack() - // Wait for balance to be updated: homeScreen.waitUntilBalanceEquals(balanceAfter) } + fun getOwnAddress(): String { + homeScreen.goToReceive() + val address = receiveScreen.address + device.pressBack() // Back to Home + return address + } + fun setupP2P( phoneNumber: UserPhoneNumber = Gen.userPhoneNumber(), verificationCode: String = Gen.numeric(6), @@ -273,10 +277,32 @@ class AutoFlows( p2pScreen.fillForm(phoneNumber, verificationCode, firstName, lastName) } - fun startOperationFromClipboardTo(addressOrInvoice: String) { - Clipboard.write(addressOrInvoice) + fun startOperationManualInputTo(destination: String) { + homeScreen.goToSend() + uriInput(R.id.uri_input).text = destination + + if (destination == ReceiveScreen.lastCopiedFromClipboard) { + labelWith(R.string.send_cyclic_payment_warning).assertExists() + + } else { + labelWith(R.string.send_cyclic_payment_warning).assertDoesntExist() + } + + muunButton(R.id.confirm).press() + } + + fun startOperationFromClipboardTo(destination: String) { + Clipboard.write(destination) + homeScreen.goToSend() - id(R.id.uri_paster).click() + + if (OS.supportsClipboardAccessNotification()) { + muunButton(R.id.paste_button).press() + muunButton(R.id.confirm).press() + + } else { + uriPaster.waitForExists().click() + } } fun sendToAddressFromClipboard(money: Money, receivingAddress: String, description: String) { @@ -355,13 +381,14 @@ class AutoFlows( newOpScreen.fillForm(amountToEnter, descriptionToEnter) // Keep these to check later: + val destination = newOpScreen.destination val amount = newOpScreen.confirmedAmount val fee = newOpScreen.confirmedFee val description = newOpScreen.confirmedDescription val total = newOpScreen.confirmedTotal newOpScreen.submit() - homeScreen.checkBalanceCloseTo(balanceBefore.subtract(total)) + checkBalanceAfterOnchainOperation(destination, balanceBefore, fee, total) checkOperationDetails(amount, description, fee) { homeScreen.goToOperationDetail(description, isPending = true) @@ -435,7 +462,7 @@ class AutoFlows( opDetailScreen.checkAmount(amount) opDetailScreen.checkDescription(description) - lightningFee?.let { + lightningFee.let { opDetailScreen.checkLightningFee(it) } @@ -446,7 +473,10 @@ class AutoFlows( * Perform submarine swap checks in success scenarios. By default, assumes we are in * Operation Detail screen. */ - fun checkSubmarineSwapSuccess(is0Conf: Boolean = true, reachOperationDetail: () -> Unit = {}) { + private fun checkSubmarineSwapSuccess( + is0Conf: Boolean = true, + reachOperationDetail: () -> Unit = {}, + ) { reachOperationDetail() @@ -474,7 +504,7 @@ class AutoFlows( private fun checkSwapConfirmed() { opDetailScreen.waitForStatusChange(R.string.operation_completed) - // TODO check receving node? + // TODO check receiving node? } fun tryAllFeesAndExit() { @@ -613,14 +643,23 @@ class AutoFlows( pressMuunButton(R.id.lnurl_intro_action) } - uriPaster.waitForExists().click() + if (OS.supportsClipboardAccessNotification()) { + id(R.id.paste_from_clipboard).click() + + } else { + uriPaster.waitForExists().click() + } - // Let's wait a sec until withdraw suceeds + // Let's wait a sec until withdraw succeeds SystemClock.sleep(10000) } - fun lnUrlWithdrawViaSend(variant: LappClient.LnUrlVariant = LappClient.LnUrlVariant.NORMAL) { - startLnUrlWithdrawViaSend(variant) + fun lnUrlWithdrawViaSend( + variant: LappClient.LnUrlVariant = LappClient.LnUrlVariant.NORMAL, + submitLnurl: (String) -> Unit = ::startOperationFromClipboardTo, + ) { + + startLnUrlWithdrawViaSend(variant, submitLnurl) if (variant == LappClient.LnUrlVariant.SLOW) { @@ -631,20 +670,19 @@ class AutoFlows( .textEquals(MuunTexts.normalize(R.string.error_op_action)) .press() } else { - // Let's wait a sec until withdraw suceeds + // Let's wait a sec until withdraw succeeds SystemClock.sleep(10000) } } - fun startLnUrlWithdrawViaSend(variant: LappClient.LnUrlVariant) { + fun startLnUrlWithdrawViaSend( + variant: LappClient.LnUrlVariant, + submitLnurl: (String) -> Unit = ::startOperationFromClipboardTo, + ) { val lnurl = LappClient().generateWithdrawLnUrl(variant) - Clipboard.write(lnurl) - println("Using lnurl: $lnurl") - homeScreen.goToSend() - - uriPaster.waitForExists().click() + submitLnurl(lnurl) pressMuunButton(R.id.lnurl_withdraw_confirm_action) } @@ -673,6 +711,24 @@ class AutoFlows( // PRIVATE, helper stuff + private fun checkBalanceAfterOnchainOperation( + destination: String, + balanceBefore: Money, + fee: MonetaryAmount, + total: MonetaryAmount, + ) { + + val lastCopiedFromClipboard = ReceiveScreen.lastCopiedFromClipboard + val expectedBalance = if (destination == UiUtils.ellipsize(lastCopiedFromClipboard)) { + // Its a cyclic payment! We just subtract the fee + balanceBefore.subtract(fee) + } else { + balanceBefore.subtract(total) + } + + homeScreen.checkBalanceCloseTo(expectedBalance) + } + private fun goToSettingsAndClickDeleteWallet() { homeScreen.goToSettings() diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/Clipboard.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/Clipboard.kt index 041a3cf8..beb4f936 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/Clipboard.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/Clipboard.kt @@ -9,8 +9,8 @@ object Clipboard { fun write(content: String) { InstrumentationRegistry.getInstrumentation().runOnMainSync { getClipboardManager().setPrimaryClip( - ClipData.newPlainText("main", content) - ); + ClipData.newPlainText("main", content) + ) } } @@ -26,8 +26,8 @@ object Clipboard { private fun getClipboardManager(): ClipboardManager { return InstrumentationRegistry - .getInstrumentation() - .targetContext - .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + .getInstrumentation() + .targetContext + .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunButton.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunButton.kt index b72949b6..a4383978 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunButton.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunButton.kt @@ -7,7 +7,7 @@ import androidx.test.uiautomator.UiObject class MuunButton( override val device: UiDevice, override val context: Context, - private val button: UiObject + private val button: UiObject, ) : WithMuunInstrumentationHelpers { fun doesntExist() { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunDialog.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunDialog.kt index 599e80ea..87be46e2 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunDialog.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunDialog.kt @@ -7,7 +7,7 @@ import io.muun.apollo.R class MuunDialog( override val device: UiDevice, - override val context: Context + override val context: Context, ) : WithMuunInstrumentationHelpers { fun checkDisplayed(@StringRes stringResId: Int) { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunTexts.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunTexts.kt index 89b5455d..fa2f9ff3 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunTexts.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunTexts.kt @@ -3,6 +3,7 @@ package io.muun.apollo.utils import androidx.annotation.StringRes import androidx.test.platform.app.InstrumentationRegistry import io.muun.apollo.presentation.ui.utils.OS +import java.util.Locale object MuunTexts { @@ -10,6 +11,6 @@ object MuunTexts { fun normalize(@StringRes id: Int): String { val text = context.getString(id) - return if (OS.shouldNormalizeTextForUiTests()) text.toUpperCase() else text + return if (OS.shouldNormalizeTextForUiTests()) text.uppercase(Locale.getDefault()) else text } } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunToolbar.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunToolbar.kt index aa978f7f..8a7a3795 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunToolbar.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/MuunToolbar.kt @@ -6,7 +6,7 @@ import io.muun.apollo.R class MuunToolbar( override val device: UiDevice, - override val context: Context + override val context: Context, ) : WithMuunInstrumentationHelpers { fun pressClose() { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemCommand.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemCommand.kt index d1900e04..5d59c3a9 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemCommand.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemCommand.kt @@ -14,10 +14,24 @@ object SystemCommand { } /** - * Grant permission specified by @param permissionName for @param packageName e.g (app) . + * Grant permission specified by @param permissionName for @param packageName e.g (app). */ fun grantPermission(packageName: String, permissionName: String) { adb("pm grant $packageName $permissionName") } + /** + * Disable display of softkeyboard. Useful to avoid tricky softkeyboard appearances making test + * flaky or non-deterministic (e.g difference running in different devices or OS versions). + */ + fun disableSoftKeyboard() { + adb("settings put secure show_ime_with_hard_keyboard 0") + } + + /** + * Enable display of softkeyboard. Reverting the effects of {@link #disableSoftKeyboard()} + */ + fun enableSoftKeyboard() { + adb("settings put secure show_ime_with_hard_keyboard 0") + } } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemContacts.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemContacts.kt index 55a302e9..3efd2e33 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemContacts.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/SystemContacts.kt @@ -33,7 +33,8 @@ object SystemContacts { private fun createRawContact(accountName: String): String { val accountType = "muunTest" // whatever - adb("content", "insert", + adb( + "content", "insert", "--uri", "content://com.android.contacts/raw_contacts", "--bind", "account_type:s:$accountType", "--bind", "account_name:s:$accountName" @@ -43,7 +44,8 @@ object SystemContacts { Thread.sleep(25) // Get new raw contact's id to return it - val output = adb("content", "query", + val output = adb( + "content", "query", "--uri", "content://com.android.contacts/raw_contacts", "--projection", "_id", "--where", "account_name=\"$accountName\"" @@ -60,7 +62,8 @@ object SystemContacts { * Set name data to a raw contact. */ private fun setNameData(id: String, name: String) { - adb("content", "insert", + adb( + "content", "insert", "--uri", "content://com.android.contacts/data", "--bind", "raw_contact_id:i:$id", "--bind", "mimetype:s:vnd.android.cursor.item/name", @@ -75,7 +78,8 @@ object SystemContacts { val phoneType = "muunTest" // whatever val phoneName = Gen.alpha(10) - adb("content", "insert", + adb( + "content", "insert", "--uri", "content://com.android.contacts/data", "--bind", "raw_contact_id:i:$id", "--bind", "mimetype:s:vnd.android.cursor.item/phone_v2", diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/TestData.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/TestData.kt index 2b9cd2b7..301b4ebe 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/TestData.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/TestData.kt @@ -1,7 +1,7 @@ package io.muun.apollo.utils -import io.muun.apollo.domain.model.user.UserPhoneNumber import io.muun.apollo.data.external.Gen +import io.muun.apollo.domain.model.user.UserPhoneNumber data class RandomUser( val email: String = Gen.email(), @@ -10,7 +10,7 @@ data class RandomUser( val pin: List = Gen.pin(), val firstName: String = Gen.alpha(6), val lastName: String = Gen.alpha(6), - val phoneNumber: UserPhoneNumber = Gen.userPhoneNumber() + val phoneNumber: UserPhoneNumber = Gen.userPhoneNumber(), ) { val fullName get() = "$firstName $lastName" } diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/UriPaster.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/UriPaster.kt index b38229f2..6bb01b1a 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/UriPaster.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/UriPaster.kt @@ -7,7 +7,7 @@ import io.muun.apollo.R class UriPaster( override val device: UiDevice, - override val context: Context + override val context: Context, ) : WithMuunInstrumentationHelpers { fun waitForExists(): UiObject { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunEspressoHelpers.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunEspressoHelpers.kt index 80cbc7a6..58fe5782 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunEspressoHelpers.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunEspressoHelpers.kt @@ -18,6 +18,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage +import io.muun.apollo.presentation.ui.utils.OS import org.hamcrest.Matcher import org.hamcrest.Matchers import org.hamcrest.Matchers.not @@ -48,7 +49,7 @@ interface WithMuunEspressoHelpers { * inside the TextView. */ fun clickClickableSpanByOrder(target: Int): ViewAction { // Of the peaky blinders!!! - return internalClickClickableSpan { index, spanText -> + return internalClickClickableSpan { index, _ -> index == target } } @@ -76,7 +77,8 @@ interface WithMuunEspressoHelpers { } // Get the links inside the TextView and check if we find textToClick - val spans = spannableString.getSpans(0, spannableString.length, ClickableSpan::class.java) + val spans = + spannableString.getSpans(0, spannableString.length, ClickableSpan::class.java) if (spans.isNotEmpty()) { for ((index, span) in spans.withIndex()) { val start = spannableString.getSpanStart(span) @@ -111,9 +113,13 @@ interface WithMuunEspressoHelpers { } fun checkToastDisplayed(resId: Int) { - checkToastDisplayed(getCurrentActivity(), resId) + if (OS.supportsEspressoToastDetection()) { + checkToastDisplayed(getCurrentActivity(), resId) + } + // Else do nothing :'( (We can't reliably detect toasts) } + // Note: Doesn't work in starting Android 12 :shrug: private fun checkToastDisplayed(activity: Activity?, @StringRes resId: Int) { onView(withText(resId)).inRoot(withDecorView(not(activity!!.window.decorView))) .check(matches(isDisplayed())) diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunInstrumentationHelpers.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunInstrumentationHelpers.kt index 20931126..035b335b 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunInstrumentationHelpers.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/WithMuunInstrumentationHelpers.kt @@ -5,9 +5,9 @@ import androidx.annotation.StringRes import androidx.test.uiautomator.* import io.muun.apollo.BuildConfig import io.muun.apollo.R +import io.muun.apollo.data.debug.LappClient import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.utils.locale -import io.muun.apollo.presentation.ui.debug.LappClient import io.muun.apollo.presentation.ui.helper.MoneyHelper import io.muun.apollo.utils.screens.* import org.assertj.core.api.Assertions.assertThat @@ -174,13 +174,13 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { homeScreen.goToSend() // Wait for a couple secs - id(R.id.home_balance_view).waitForExists(3000) + busyWait(3000) // Go back home and pull notifications device.pressBack() // Wait make sure we reached home - assertThat(id(R.id.home_balance_view).waitForExists(3000)).isTrue() + id(R.id.home_balance_view).assertExists() } /** Obtain a view matching a normalized string. @@ -201,6 +201,13 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { /** Obtain a view matching a string, that must be contained somewhere. */ fun labelWith(text: String): UiObject = device.findObject(UiSelector().textContains(text)) + /** + * Obtain a view matching a regex, that must MATCH the WHOLE content of a node/UiObject. E.g + * UiAutomator uses Matcher#matches() instead of Matcher#find() so the regex must be a perfect + * match with the input string. + */ + fun labelMatches(regex: String): UiObject = device.findObject(UiSelector().textMatches(regex)) + /** Obtain a view matching a string, that must be contained somewhere. */ fun labelWith(@StringRes stringResId: Int): UiObject = device.findObject(UiSelector().textContains(context.getString(stringResId))) @@ -210,7 +217,7 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { device.findObject(UiSelector().description(context.getString(stringResId))) /** Obtain a view (if it exists) matching by id. */ - fun maybeViewId(@IdRes id: Int): UiObject2 = device.findObject(By.res(resourceName(id))) + fun maybeViewId(@IdRes id: Int): UiObject2? = device.findObject(By.res(resourceName(id))) /** * Obtain a view (waiting for it to exist) matching by id resource. @@ -237,8 +244,8 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { fun detailItemContent(@IdRes id: Int): UiObject = detailItem(id).getChild(idSelector(R.id.operation_detail_item_text_content)) - fun maybeDetailItemTitle(@IdRes id: Int): UiObject2 = - maybeViewId(id).findObject(By.res(resourceName(R.id.operation_detail_item_text_title))) + fun maybeDetailItemTitle(@IdRes id: Int): UiObject2? = + maybeViewId(id)?.findObject(By.res(resourceName(R.id.operation_detail_item_text_title))) /** Obtain a MuunDetailItem's title, matching by id resource name. */ fun detailItemTitle(@IdRes id: Int): UiObject = @@ -257,12 +264,17 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { fun settingsItemContent(@IdRes id: Int): UiObject = detailItem(id).getChild(idSelector(R.id.setting_item_description)) + /** Obtain a MuunUriInput, matching by id resource name. */ + fun uriInput(@IdRes id: Int): UiObject = + id(id).getChild(idSelector(R.id.text_input)) + /** Obtain a MuunTextInput, matching by id resource name. */ fun input(@IdRes id: Int): UiObject = id(id).getChild(idSelector(R.id.muun_text_input_edit_text)) - fun inputError(@IdRes id: Int): UiObject2 = - maybeViewId(id).wait(Until.findObject(ByShortName("textinput_error")), 30000) + fun inputError(@IdRes id: Int): UiObject { + return id(id).getChild(idSelector(R.id.textinput_error)).await(30000) + } /** Obtain a Muun's empty screen action button, matching by id resource name. */ fun emptyScreenButton(@IdRes id: Int): UiObject = @@ -296,8 +308,13 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { } } - fun waitUntilGone(@IdRes id: Int) = - device.wait(Until.gone(By.res(resourceName(id))), 4000) ?: false + fun busyWait(millis: Long) { + device.wait(Until.hasObject(By.text("busy-wait-until-timeout")), millis) + } + + fun waitUntilGone(@IdRes id: Int) { + assertThat(device.wait(Until.gone(By.res(resourceName(id))), 4000)).isTrue + } /** * Press the BACK key until a view with a given ID exists on screen, no more than `limit` @@ -323,7 +340,7 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { Thread.sleep(seconds * 1000) fun assertMoneyEqualsWithRoundingHack(actual: MonetaryAmount, expected: MonetaryAmount) { - assertThat(actual.currency == expected.currency) + assertThat(actual.currency == expected.currency).isTrue val margin = if (actual.currency.currencyCode == "BTC") { moneyEqualsRoundingMarginBTC @@ -346,7 +363,7 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { fun pressMuunButtonAndWaitForNewWindow(@IdRes id: Int) { val buttonObject = button(id) - assertThat(buttonObject.isEnabled).isTrue() + assertThat(buttonObject.isEnabled).isTrue buttonObject.clickAndWaitForNewWindow() } @@ -355,12 +372,16 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { assertThat(inputError(id).text).isEqualTo(context.getString((expectedErrorId))) } + fun checkScreenFor(regex: String) { + labelMatches(regex).assertExists() + } + fun checkScreenShows(text: String) { - assert(labelWith(text).waitForExists(2000)) + labelWith(text).assertExists() } fun checkScreenShows(@StringRes stringResId: Int) { - assert(labelWith(stringResId).waitForExists(2000)) + labelWith(stringResId).assertExists() } private fun idSelector(@IdRes id: Int): UiSelector = UiSelector().resourceId(resourceName(id)) @@ -391,6 +412,15 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { } } + // TODO we should wrap all usages of UiObject#click() and UiDevice#pressBack() with this + fun debug(action: () -> Boolean) { + val result = action() + val stackTraceElement = Thread.currentThread().stackTrace[4] + val caller = stackTraceElement.methodName + val callersLineNumber = stackTraceElement.lineNumber + println("Debug: $caller#L$callersLineNumber: $result") + } + /* Extension functions */ fun String.toMoney(): Money = @@ -451,17 +481,19 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { fun String.dropUnit() = split(" ")[0] - fun UiObject.child(@IdRes childResId: Int) = + fun UiObject.child(@IdRes childResId: Int): UiObject = getChild(idSelector(childResId)) fun UiObject.await() = await(2000) - fun UiObject.await(millis: Long) = - waitForExists(millis) + fun UiObject.await(millis: Long): UiObject { + assertThat(this.waitForExists(millis)).isTrue + return this + } fun UiObject.assertExists() { - assertThat(this.waitForExists(3000)).isTrue() + assertThat(this.waitForExists(3000)).isTrue } fun UiObject.assertDoesntExist() { @@ -472,7 +504,7 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { // Do nothing } - assertThat(this.exists()).isFalse() + assertThat(this.exists()).isFalse } fun UiObject.assertTextEquals(expectedText: String) { @@ -492,7 +524,7 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { } fun UiObject.assertDisabled() { - assertThat(this.isEnabled).isFalse() + assertThat(this.isEnabled).isFalse } fun UiObject.assertEnabledAndClick(): Boolean { @@ -502,10 +534,9 @@ interface WithMuunInstrumentationHelpers : WithMuunEspressoHelpers { // I WISH I could made these extension functions but we can't (as of this writing) static // static extension methods of JAVA classes (we can if the extended class is in Kotlin) - fun Byid(@IdRes id: Int): BySelector = - ByShortName(resourceShortName(id)) + fun byId(@IdRes id: Int): BySelector = + byShortName(resourceShortName(id)) - fun ByShortName(resourceShortName: String) = + fun byShortName(resourceShortName: String): BySelector = By.res(BuildConfig.APPLICATION_ID, resourceShortName) - } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/adb.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/adb.kt index 480b28a8..5e10bf0a 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/adb.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/adb.kt @@ -15,7 +15,7 @@ object adb { Log.d("ADB", "Cmd: $cmdLine") val fd = InstrumentationRegistry.getInstrumentation().uiAutomation - .executeShellCommand(cmdLine) + .executeShellCommand(cmdLine) val baos = ByteArrayOutputStream() writeDataToByteStream(fd!!, baos) @@ -39,7 +39,8 @@ object adb { */ @Throws(IOException::class) private fun writeDataToByteStream( - pfDescriptor: ParcelFileDescriptor, outputStream: ByteArrayOutputStream) { + pfDescriptor: ParcelFileDescriptor, outputStream: ByteArrayOutputStream, + ) { val inputStream = ParcelFileDescriptor.AutoCloseInputStream(pfDescriptor) try { val buffer = ByteArray(BUFFER_SIZE) diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ChangePasswordScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ChangePasswordScreen.kt index ce4512f7..dd450439 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ChangePasswordScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ChangePasswordScreen.kt @@ -5,12 +5,11 @@ import androidx.test.uiautomator.UiDevice import io.muun.apollo.R import io.muun.apollo.utils.RandomUser import io.muun.apollo.utils.WithMuunInstrumentationHelpers -import org.assertj.core.api.Assertions.assertThat class ChangePasswordScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun fillForm(user: RandomUser, newPassword: String) { @@ -63,7 +62,7 @@ class ChangePasswordScreen( } private fun checkEmailVerificationScreenDisplayed(email: String) { - assertThat(id(R.id.signup_waiting_for_email_verification_title).await()).isTrue() + id(R.id.signup_waiting_for_email_verification_title).await() checkScreenShows(email) } diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmailPasswordSetupScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmailPasswordSetupScreen.kt index 6edb600f..8a30c882 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmailPasswordSetupScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmailPasswordSetupScreen.kt @@ -4,12 +4,11 @@ import android.content.Context import androidx.test.uiautomator.UiDevice import io.muun.apollo.R import io.muun.apollo.utils.WithMuunInstrumentationHelpers -import org.assertj.core.api.Assertions.assertThat class EmailPasswordSetupScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun skipSetup() { pressMuunButton(R.id.create_email_skip) @@ -28,7 +27,7 @@ class EmailPasswordSetupScreen( } fun checkEmailVerificationScreenDisplayed(email: String) { - assertThat(id(R.id.verify_email_title).await(10000)).isTrue() + id(R.id.verify_email_title).await(10000) checkScreenShows(email) } diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmergencyKitSetupScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmergencyKitSetupScreen.kt index 8a298082..474fb500 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmergencyKitSetupScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/EmergencyKitSetupScreen.kt @@ -7,26 +7,34 @@ import io.muun.apollo.utils.WithMuunInstrumentationHelpers class EmergencyKitSetupScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun doCompleteFlow(sniffActivationCode: () -> String) { val id = id(R.id.pager) - id.swipeLeft(100) // equals 1 page slide - id.swipeLeft(100) // equals 1 page slide + + // We can't use the very comfy swipeLeft since for Android 13 it's impl trigger predictive + // back gesture, so... we swipe in some coordinates of the screen like the cavemen we are. + val rect = id.visibleBounds + device.swipe(rect.centerX(), rect.centerY(), rect.left, rect.centerY(), 20) // 1 page slide + device.swipe(rect.centerX(), rect.centerY(), rect.left, rect.centerY(), 20) // 1 page slide + device.swipe(rect.centerX(), rect.centerY(), rect.left, rect.centerY(), 20) // 1 page slide + pressMuunButton(R.id.accept) sleep() // Wait a little bit for EK to be generated // What follows is a little flaky. The only export option we can use in this testing context // is send-by-email, which will open an external application. We assume that this device - // has exactly 1 email application installed, which is true for emulators and Bitrise. + // has exactly gmail application installed, which is true for emulators and Bitrise. // Use the email option, give the other app a moment to open, then return to Muun: - // TODO FIX THIS. Export EK flow changed - // id(R.id.save_option_email).click() + id(R.id.save_link_manual).click() + muunButton(R.id.dialog_confirm).press() + label("Gmail").click() + sleep(3) backUntilExists(R.id.ek_verify_action) diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/HomeScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/HomeScreen.kt index ffa31877..7c3f6de2 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/HomeScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/HomeScreen.kt @@ -8,7 +8,6 @@ import androidx.test.uiautomator.UiObjectNotFoundException import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import io.muun.apollo.R -import io.muun.apollo.presentation.ui.helper.MoneyHelper import io.muun.apollo.presentation.ui.helper.isBtc import io.muun.apollo.utils.WithMuunInstrumentationHelpers import io.muun.apollo.utils.WithMuunInstrumentationHelpers.Companion.balanceNotEqualsErrorMessage @@ -19,13 +18,13 @@ import javax.money.MonetaryAmount class HomeScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { val balanceInBtc get() = id(R.id.balance_main_currency_amount).text.toBtcMoney() - fun waitUntilVisible(): Boolean { - return id(R.id.home_balance_view).waitForExists(5000) + fun waitUntilVisible() { + id(R.id.home_balance_view).await(10000) } fun waitForWelcomeDialog(): Boolean { @@ -34,13 +33,13 @@ class HomeScreen( } fun waitUntilBalanceEquals(expectedBalance: MonetaryAmount) { - val result = pullNotificationsUntil(20000 ) { + val result = pullNotificationsUntil(20000) { balanceEquals(expectedBalance) } assertThat(result) .withFailMessage("$balanceNotEqualsErrorMessage:$expectedBalance") - .isTrue() + .isTrue } fun checkBalanceCloseTo(expectedBalance: MonetaryAmount) { @@ -91,7 +90,7 @@ class HomeScreen( // click on the exact area where the view is (after all its moving, maybe the clickable area // is also moving, or its always at the final place where the view ends after animation). // Sooooo, we wait for a bit :). This right here kills the flakiness we were having. - id(R.id.signup_start).waitForExists(1500) + busyWait(1500) } private fun openOperationDetail(description: String, isPending: Boolean) { @@ -103,7 +102,7 @@ class HomeScreen( // This probably means we missed an opUpdate notif. Let's try again, flakiness is ugly. // Wait 1.5 secs... - id(R.id.signup_start).waitForExists(1500) + busyWait(1500) // Try again clickOperation(isPending, description) @@ -140,7 +139,18 @@ class HomeScreen( private fun balanceEquals(expectedBalance: MonetaryAmount): Boolean { Preconditions.checkArgument(expectedBalance.isBtc()) - return maybeViewId(R.id.balance_main_currency_amount) + + val maybeBalanceView = maybeViewId(R.id.balance_main_currency_amount) + + @Suppress("FoldInitializerAndIfToElvis") // Prefer if for readability (familiarity) + if (maybeBalanceView == null) { + return false + } + + val balance = maybeBalanceView.text + println("balanceEquals: actual: $balance, expected: $expectedBalance, short: ${expectedBalance.toShortText()}") + + return maybeBalanceView .wait(Until.textContains(expectedBalance.toShortText()), 1000) } } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ManualFeeScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ManualFeeScreen.kt index e68e6403..f6f1013e 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ManualFeeScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ManualFeeScreen.kt @@ -3,17 +3,17 @@ package io.muun.apollo.utils.screens import android.content.Context import androidx.test.uiautomator.UiDevice import io.muun.apollo.R -import io.muun.apollo.domain.utils.locale import io.muun.apollo.utils.WithMuunInstrumentationHelpers import io.muun.apollo.utils.screens.RecommendedFeeScreen.OnScreenFeeOption +import java.text.DecimalFormat class ManualFeeScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun editFeeRate(feeRate: Double): OnScreenFeeOption { - feeInput.text = feeRate.toString() + feeInput.text = DecimalFormat.getInstance(locale).format(feeRate); return feeOption } @@ -21,13 +21,15 @@ class ManualFeeScreen( pressMuunButton(R.id.confirm_fee) } - private val feeOption get() = - OnScreenFeeOption( - feeInput.text.parseDecimal(), - id(R.id.fee_main_value).text.toMoney(), - id(R.id.fee_secondary_value).text.dropParenthesis().toMoney() - ) + private val feeOption + get() = + OnScreenFeeOption( + feeInput.text.parseDecimal(), + id(R.id.fee_main_value).text.toMoney(), + id(R.id.fee_secondary_value).text.dropParenthesis().toMoney() + ) - private val feeInput get() = - id(R.id.fee_manual_input).child(R.id.fee_input) + private val feeInput + get() = + id(R.id.fee_manual_input).child(R.id.fee_input) } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/NewOperationScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/NewOperationScreen.kt index ea912d4a..a60ab30b 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/NewOperationScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/NewOperationScreen.kt @@ -13,27 +13,31 @@ import io.muun.common.model.DebtType import io.muun.common.utils.BitcoinUtils import io.muun.common.utils.LnInvoice import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.withinPercentage import javax.money.MonetaryAmount class NewOperationScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { - val confirmedAmount get() = - id(R.id.selected_amount).text.toMoney() + val destination: String + get() = id(R.id.target_address).text - val confirmedFee get() = - id(R.id.fee_amount).text.toMoney() + val confirmedAmount: MonetaryAmount + get() = id(R.id.selected_amount).text.toMoney() - val confirmedDescription get() = - id(R.id.notes_content).text + val confirmedFee: MonetaryAmount + get() = id(R.id.fee_amount).text.toMoney() - val confirmedTotal get() = - id(R.id.total_amount).text.toMoney() + val confirmedDescription: String + get() = id(R.id.notes_content).text + + val confirmedTotal: MonetaryAmount + get() = id(R.id.total_amount).text.toMoney() fun waitUntilVisible() { - button(R.id.muun_next_step_button).waitForExists(15000) + button(R.id.muun_next_step_button).await(15000) } fun assertSubmitIsDisabled() = @@ -41,10 +45,9 @@ class NewOperationScreen( fun fillForm(amount: MonetaryAmount?, description: String?) { - checkInputAmountCurrenciesMatch() - // Enter the requested data, moving forward: if (amount != null) { + checkInputAmountCurrenciesMatch() editAmount(amount) goNext() } @@ -74,13 +77,11 @@ class NewOperationScreen( fun fillForm( invoice: LnInvoice, description: String? = null, - debtType: DebtType = DebtType.NONE + debtType: DebtType = DebtType.NONE, ) { waitForResolveOperationUri() - checkInputAmountCurrenciesMatch() - if (description != null) { editDescription(description) goNext() @@ -134,12 +135,12 @@ class NewOperationScreen( fun checkConfirmedData( amount: MonetaryAmount? = null, description: String? = null, - fee: OnScreenFeeOption? = null + fee: OnScreenFeeOption? = null, ) { checkStep(NewOperationStep.CONFIRM) - assertThat(confirmedDescription).isNotEmpty() + assertThat(confirmedDescription).isNotEmpty assertMoneyEqualsWithRoundingHack(confirmedAmount.add(confirmedFee), confirmedTotal) if (amount != null) { @@ -165,11 +166,11 @@ class NewOperationScreen( // TODO: abstract this a little if it becomes necessary elsewhere String.format( "\nExpecting:\n <%s>\nto be close to:\n <%s>\n" + - "by less than <%s> but difference was <%s>.\n" + - "(a difference of exactly <%s> being considered valid)\n" + - "OR to be close to:\n <%s>\n" + - "by less than <%s> but difference was <%s>.%n" + - "(a difference of exactly <%s> being considered valid)\n", + "by less than <%s> but difference was <%s>.\n" + + "(a difference of exactly <%s> being considered valid)\n" + + "OR to be close to:\n <%s>\n" + + "by less than <%s> but difference was <%s>.%n" + + "(a difference of exactly <%s> being considered valid)\n", confirmedFee.toString(), fee.primaryAmount.toString(), moneyEqualsRoundingMarginBTC.toString(), @@ -190,7 +191,7 @@ class NewOperationScreen( private fun checkConfirmedData( invoice: LnInvoice, desc: String, - debtType: DebtType = DebtType.NONE + debtType: DebtType = DebtType.NONE, ) { checkStep(NewOperationStep.CONFIRM) @@ -200,7 +201,7 @@ class NewOperationScreen( rotateAmountCurrencies() } - assertThat(confirmedDescription).isNotEmpty() + assertThat(confirmedDescription).isNotEmpty val total = confirmedAmount.add(confirmedFee) @@ -213,12 +214,16 @@ class NewOperationScreen( val confirmedFeeInSat = BitcoinUtils.bitcoinsToSatoshis(confirmedFee) - // Let's check swap fee has a "reasonable amount" (e.g is in certain aprox range) + // Let's check swap fee has a "reasonable amount" (e.g is in certain approx range) if (debtType == DebtType.LEND) { - assertThat(confirmedFeeInSat < 2) // No on-chain swap => no on-chain fees, only ln fee + // No on-chain swap => no on-chain fees, only ln fee + // Ln fees are currently approx 0.05% of amount for LEND swaps + assertThat(confirmedFeeInSat) + .isCloseTo((invoice.amount.amountInSatoshis * 0.0005).toLong(), withinPercentage(5)) } else { // Normal & Collect swaps - assertThat(confirmedFeeInSat > 220) // On-chain (226 * 1 sat/vbyte) + sweep + ln fee + // On-chain (~219/226 * 1 sat/vbyte) + sweep + ln fee + assertThat(confirmedFeeInSat > 200).isTrue } } @@ -234,14 +239,16 @@ class NewOperationScreen( private fun checkStep(step: NewOperationStep) = assertThat(detectStep()).isEqualTo(step) + // @formatter:off private fun detectStep() = when { - id(R.id.new_operation_resolving).exists() -> NewOperationStep.RESOLVING - id(R.id.muun_amount).exists() -> NewOperationStep.ENTER_AMOUNT - id(R.id.muun_note_input).exists() -> NewOperationStep.ENTER_DESCRIPTION - id(R.id.total_amount).exists() -> NewOperationStep.CONFIRM + id(R.id.new_operation_resolving).exists() -> NewOperationStep.RESOLVING + id(R.id.muun_amount).exists() -> NewOperationStep.ENTER_AMOUNT + id(R.id.muun_note_input).exists() -> NewOperationStep.ENTER_DESCRIPTION + id(R.id.total_amount).exists() -> NewOperationStep.CONFIRM else -> throw RuntimeException("Cannot detect new operation step") } + // @formatter:on } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/OperationDetailScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/OperationDetailScreen.kt index 218287e4..ca0cbdcb 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/OperationDetailScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/OperationDetailScreen.kt @@ -13,8 +13,8 @@ import javax.money.MonetaryAmount class OperationDetailScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun waitForStatusChange(@StringRes stringResId: Int) { waitForStatusChange(context.getString(stringResId)) @@ -22,8 +22,14 @@ class OperationDetailScreen( fun waitForStatusChange(statusText: String) { scrollToFind(detailItemTitle(R.id.operation_detail_status)) - maybeDetailItemTitle(R.id.operation_detail_status) - .wait(Until.textContains(statusText), 15000) + val exitCondition = { + maybeDetailItemTitle(R.id.operation_detail_status) + ?.wait(Until.textContains(statusText), 15000) ?: false + } + + doUntil(15000, exitCondition) { + // Nothing, we just want to safely wait until condition + } } fun checkStatus(vararg statusTexts: String) { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ReceiveScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ReceiveScreen.kt index d6e22b09..5e88562c 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ReceiveScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/ReceiveScreen.kt @@ -14,17 +14,24 @@ class ReceiveScreen( override val context: Context, ) : WithMuunInstrumentationHelpers { + companion object { + var lastCopiedFromClipboard: String = "" + private set + } + val address: String get() { id(R.id.show_qr_copy).click() - return Clipboard.read() + lastCopiedFromClipboard = Clipboard.read() + return lastCopiedFromClipboard } val invoice: String get() { normalizedLabel(ShowQrPage.LN.titleRes).click() id(R.id.show_qr_copy).click() - return Clipboard.read() + lastCopiedFromClipboard = Clipboard.read() + return lastCopiedFromClipboard } fun goToScanLnUrl() { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/RecommendedFeeScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/RecommendedFeeScreen.kt index b8982a18..ed79b673 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/RecommendedFeeScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/RecommendedFeeScreen.kt @@ -9,13 +9,13 @@ import javax.money.MonetaryAmount class RecommendedFeeScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { class OnScreenFeeOption( val feeRate: Double, val primaryAmount: MonetaryAmount, - val secondaryAmount: MonetaryAmount + val secondaryAmount: MonetaryAmount, ) fun selectFeeOptionFast() = diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SecurityCenterScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SecurityCenterScreen.kt index c21453e0..270c1933 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SecurityCenterScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SecurityCenterScreen.kt @@ -7,8 +7,8 @@ import io.muun.apollo.utils.WithMuunInstrumentationHelpers class SecurityCenterScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun goToEmailAndPassword() { id(R.id.task_email).click() diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SettingsScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SettingsScreen.kt index eba1d41c..4c53c814 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SettingsScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SettingsScreen.kt @@ -3,13 +3,12 @@ package io.muun.apollo.utils.screens import android.content.Context import androidx.test.uiautomator.UiDevice import io.muun.apollo.R -import io.muun.apollo.utils.MuunTexts import io.muun.apollo.utils.WithMuunInstrumentationHelpers class SettingsScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun goToChangePassword() { id(R.id.settings_password).click() @@ -26,7 +25,7 @@ class SettingsScreen( fun setBitcoinUnitToSat() { id(R.id.settings_bitcoin_unit).click() - id(R.id.bitcoin_unit_sat) + id(R.id.bitcoin_unit_sat).click() } fun toggleTurboChannels() { diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SetupP2PScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SetupP2PScreen.kt index 02a44576..f853c1c5 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SetupP2PScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SetupP2PScreen.kt @@ -3,20 +3,22 @@ package io.muun.apollo.utils.screens import android.content.Context import androidx.test.uiautomator.UiDevice import io.muun.apollo.R -import io.muun.apollo.domain.model.P2PSetupStep import io.muun.apollo.data.external.Gen +import io.muun.apollo.domain.model.P2PSetupStep import io.muun.apollo.domain.model.user.UserPhoneNumber import io.muun.apollo.utils.WithMuunInstrumentationHelpers class SetupP2PScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { - fun fillForm(phoneNumber: UserPhoneNumber = Gen.userPhoneNumber(), - verificationCode: String = Gen.numeric(6), - firstName: String = Gen.alpha(5), - lastName: String = Gen.alpha(5)) { + fun fillForm( + phoneNumber: UserPhoneNumber = Gen.userPhoneNumber(), + verificationCode: String = Gen.numeric(6), + firstName: String = Gen.alpha(5), + lastName: String = Gen.alpha(5), + ) { editPhoneNumber(phoneNumber) goNext() @@ -59,16 +61,17 @@ class SetupP2PScreen( pressMuunButton(R.id.signup_continue) } + // @formatter:off private fun checkStep(step: P2PSetupStep?) { when (step) { - P2PSetupStep.PHONE -> id(R.id.signup_phone_number_edit_country_prefix).await() - P2PSetupStep.CONFIRM_PHONE -> id(R.id.signup_verification_text_code).await() - P2PSetupStep.PROFILE -> id(R.id.signup_profile_edit_last_name).await() - P2PSetupStep.SYNC_CONTACTS -> id(R.id.sync_contacts_button).await() + P2PSetupStep.PHONE -> id(R.id.signup_phone_number_edit_country_prefix).await() + P2PSetupStep.CONFIRM_PHONE -> id(R.id.signup_verification_text_code).await() + P2PSetupStep.PROFILE -> id(R.id.signup_profile_edit_last_name).await() + P2PSetupStep.SYNC_CONTACTS -> id(R.id.sync_contacts_button).await() else -> throw RuntimeException("Cannot detect P2P setup step") } } - + // @formatter:on } \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SignInScreen.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SignInScreen.kt index c6aa39b8..25095b01 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SignInScreen.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/utils/screens/SignInScreen.kt @@ -5,20 +5,21 @@ import androidx.test.uiautomator.UiDevice import io.muun.apollo.R import io.muun.apollo.data.external.Gen import io.muun.apollo.utils.WithMuunInstrumentationHelpers -import org.assertj.core.api.Assertions.assertThat class SignInScreen( override val device: UiDevice, - override val context: Context -): WithMuunInstrumentationHelpers { + override val context: Context, +) : WithMuunInstrumentationHelpers { fun waitForLanding(): Boolean { return normalizedLabel(R.string.signup_start).waitForExists(2500) } - fun fillSignInForm(email: String = Gen.email(), - password: String?, - recoveryCodeParts: List?) { + fun fillSignInForm( + email: String = Gen.email(), + password: String?, + recoveryCodeParts: List?, + ) { startLogin() @@ -42,7 +43,7 @@ class SignInScreen( } } - fun rcSignIn(recoveryCodeParts: List , email: String? = null) { + fun rcSignIn(recoveryCodeParts: List, email: String? = null) { startLogin() @@ -78,13 +79,17 @@ class SignInScreen( } fun checkEmailVerificationScreenDisplayed(email: String) { - assertThat(id(R.id.signup_waiting_for_email_verification_title).await(20000)).isTrue() + id(R.id.signup_waiting_for_email_verification_title).await(20000) checkScreenShows(email) } private fun checkRcLoginEmailAuthScreenDisplayed(email: String) { - assertThat(id(R.id.rc_login_email_auth_title).await(20000)).isTrue() - checkScreenShows(email) + id(R.id.rc_login_email_auth_title).await(20000) + + val firstTwoLetters = email.substring(0, 2) + val tld = email.substring(email.indexOf('.') + 1) + val obfuscatedEmailRegex = "$firstTwoLetters.*@.*\\.$tld" + checkScreenFor(".*$obfuscatedEmailRegex.*") // regex must perfectly match whole string waitUntilGone(R.id.rc_login_email_auth_title) } @@ -111,7 +116,12 @@ class SignInScreen( } fun checkEmailConfirmEnabled(enabled: Boolean) { - assertThat(button(R.id.enter_email_action).isEnabled).isEqualTo(enabled) + if (enabled) { + button(R.id.enter_email_action).assertEnabled() + + } else { + button(R.id.enter_email_action).assertDisabled() + } } fun checkEmailError() { @@ -119,11 +129,16 @@ class SignInScreen( } fun checkPasswordConfirmEnabled(enabled: Boolean) { - assertThat(button(R.id.signup_continue).isEnabled).isEqualTo(enabled) + if (enabled) { + button(R.id.signup_continue).assertEnabled() + + } else { + button(R.id.signup_continue).assertDisabled() + } } fun checkPasswordError() { - checkInputError(R.id.signup_unlock_edit_password, R.string.error_incorrect_password) + checkInputError(R.id.signup_unlock_edit_password, R.string.error_incorrect_password) } fun abortDialogCancel() { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java index 7a310ec7..29c5cd3a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java @@ -1,6 +1,7 @@ package io.muun.apollo.presentation.app; import io.muun.apollo.data.async.tasks.MuunWorkerFactory; +import io.muun.apollo.data.debug.HeapDumper; import io.muun.apollo.data.di.DaggerDataComponent; import io.muun.apollo.data.di.DataComponent; import io.muun.apollo.data.di.DataModule; @@ -92,6 +93,9 @@ protected void attachBaseContext(Context base) { public void onCreate() { super.onCreate(); + initializeStaticSingletons(); + + HeapDumper.init(this); Crashlytics.init(this); ensureCurrencyServicesLoaded(); @@ -103,7 +107,6 @@ public void onCreate() { FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false); } - initializeStaticSingletons(); initializeDagger(); detectAppUpdate.run(); @@ -176,7 +179,7 @@ private void setNightMode() { @Override public Configuration getWorkManagerConfiguration() { Timber.d("[MuunWorkerFactory] Application#getWorkManagerConfiguration()"); - final int loggingLevel = Globals.INSTANCE.isReleaseBuild() ? Log.ASSERT : Log.VERBOSE; + final int loggingLevel = Globals.INSTANCE.isReleaseBuild() ? Log.ERROR : Log.VERBOSE; return new Configuration.Builder() .setWorkerFactory(new MuunWorkerFactory(this)) .setMinimumLoggingLevel(loggingLevel) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.java deleted file mode 100644 index d910cf64..00000000 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.muun.apollo.presentation.app; - -import io.muun.apollo.BuildConfig; -import io.muun.apollo.data.external.Globals; -import io.muun.common.bitcoinj.NetworkParametersHelper; - -import android.os.Build; -import org.bitcoinj.core.NetworkParameters; - -public class GlobalsImpl extends Globals { - - private final NetworkParameters network = NetworkParametersHelper - .getNetworkParametersFromName(BuildConfig.NETWORK_NAME); - - @Override - public String getApplicationId() { - return BuildConfig.APPLICATION_ID; - } - - @Override - public boolean isDebugBuild() { - return BuildConfig.DEBUG; - } - - @Override - public String getBuildType() { - return BuildConfig.BUILD_TYPE; - } - - @Override - public String getOldBuildType() { - return BuildConfig.OLD_BUILD_TYPE; - } - - @Override - public int getVersionCode() { - return BuildConfig.VERSION_CODE; - } - - @Override - public String getVersionName() { - return BuildConfig.VERSION_NAME; - } - - @Override - public NetworkParameters getNetwork() { - return network; - } - - @Override - public String getDeviceName() { - return Build.DEVICE; - } - - @Override - public String getDeviceModel() { - return Build.MODEL; - } - - @Override - public String getDeviceManufacturer() { - return Build.MANUFACTURER; - } - - @Override - public String getFingerprint() { - return Build.FINGERPRINT; - } - - @Override - public String getHardware() { - return Build.HARDWARE; - } - - @Override - public String getBootloader() { - return Build.BOOTLOADER; - } - - @Override - public String getMuunLinkHost() { - return BuildConfig.MUUN_LINK_HOST; - } - - @Override - public String getVerifyLinkPath() { - return BuildConfig.VERIFY_LINK_PATH; - } - - @Override - public String getAuthorizeLinkPath() { - return BuildConfig.AUTHORIZE_LINK_PATH; - } - - @Override - public String getConfirmLinkPath() { - return BuildConfig.CONFIRM_LINK_PATH; - } - - @Override - public String getRcLoginAuthorizePath() { - return BuildConfig.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 new file mode 100644 index 00000000..37e4ba81 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/GlobalsImpl.kt @@ -0,0 +1,67 @@ +package io.muun.apollo.presentation.app + +import android.os.Build +import io.muun.apollo.BuildConfig +import io.muun.apollo.data.external.Globals +import io.muun.common.bitcoinj.NetworkParametersHelper +import org.bitcoinj.core.NetworkParameters + +class GlobalsImpl : Globals() { + + override val network: NetworkParameters = NetworkParametersHelper + .getNetworkParametersFromName(BuildConfig.NETWORK_NAME) + + override val applicationId: String + get() = BuildConfig.APPLICATION_ID + + override val isDebugBuild: Boolean + get() = BuildConfig.DEBUG + + override val buildType: String + get() = BuildConfig.BUILD_TYPE + + override val oldBuildType: String + get() = BuildConfig.OLD_BUILD_TYPE + + override val versionCode: Int + get() = BuildConfig.VERSION_CODE + + override val versionName: String + get() = BuildConfig.VERSION_NAME + + override val deviceName: String + get() = Build.DEVICE + + override val deviceModel: String + get() = Build.MODEL + + override val deviceManufacturer: String + get() = Build.MANUFACTURER + + override val fingerprint: String + get() = Build.FINGERPRINT + + override val hardware: String + get() = Build.HARDWARE + + override val bootloader: String + get() = Build.BOOTLOADER + + override val muunLinkHost: String + get() = BuildConfig.MUUN_LINK_HOST + + override val verifyLinkPath: String + get() = BuildConfig.VERIFY_LINK_PATH + + override val authorizeLinkPath: String + get() = BuildConfig.AUTHORIZE_LINK_PATH + + override val confirmLinkPath: String + get() = BuildConfig.CONFIRM_LINK_PATH + + override val rcLoginAuthorizePath: String + get() = BuildConfig.RC_LOGIN_AUTHORIZE_LINK_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/app/Navigator.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java index fe530887..1e68b0bd 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java @@ -3,7 +3,6 @@ import io.muun.apollo.BuildConfig; import io.muun.apollo.R; import io.muun.apollo.data.external.Globals; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.domain.action.LogoutActions; import io.muun.apollo.domain.analytics.AnalyticsEvent; import io.muun.apollo.domain.analytics.NewOperationOrigin; @@ -482,7 +481,7 @@ public void navigateToLogout(@NotNull Context context) { * Restart the application. */ public void navigateToLauncher(@NotNull Context context) { - Crashlytics.logBreadcrumb("Navigating to LauncherActivity"); + Timber.i("Navigating to LauncherActivity"); final Intent intent = new Intent(context, LauncherActivity.class); clearBackStack(intent); diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/NotificationServiceImpl.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/NotificationServiceImpl.kt index 99a9ead9..e5954457 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/NotificationServiceImpl.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/NotificationServiceImpl.kt @@ -222,9 +222,11 @@ class NotificationServiceImpl @Inject constructor( HomeActivity.getStartActivityIntent(context) ) - notification.actions.add(MuunNotification.Action( - 0, context.string(R.string.notification_incoming_ln_payment_pending_cta) - )) + notification.actions.add( + MuunNotification.Action( + 0, context.string(R.string.notification_incoming_ln_payment_pending_cta) + ) + ) showWithDrawable(notification, R.drawable.lightning) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ApplicationLockExtension.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ApplicationLockExtension.java index 3c2b32fc..e6d0cdbb 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ApplicationLockExtension.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ApplicationLockExtension.java @@ -1,6 +1,5 @@ package io.muun.apollo.presentation.ui.activity.extension; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.data.os.execution.ExecutionTransformerFactory; import io.muun.apollo.domain.ApplicationLockManager; import io.muun.apollo.domain.analytics.Analytics; @@ -87,10 +86,11 @@ public void setRequireUnlock(boolean requireUnlock) { } private void showLockOverlay() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#showLockOverlay"); + if (lockOverlay == null) { lockOverlay = new MuunLockOverlay(getActivity()); - lockOverlay.setFingerprintAllowed(false); // never, for now lockOverlay.setListener(new BoundLockOverlayListener()); lockOverlay.attachToRoot(); @@ -103,6 +103,7 @@ private void showLockOverlay() { } private void hideLockOverlay() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#hideLockOverlay"); if (lockOverlay != null) { lockOverlay.setListener(null); lockOverlay.detachFromRoot(); @@ -148,7 +149,7 @@ private void showSoftInput(View target) { } private void updateLockOverlayAttempts() { - Crashlytics.logBreadcrumb("ApplicationLockExtension: updateLockOverlayAttempts"); + Timber.i("ApplicationLockExtension: updateLockOverlayAttempts"); lockOverlay.setRemainingAttempts( lockManager.getRemainingAttempts(), @@ -157,7 +158,7 @@ private void updateLockOverlayAttempts() { } private void onUnlockAttemptFailure() { - Crashlytics.logBreadcrumb("ApplicationLockExtension: onUnlockAttemptFailure"); + Timber.i("ApplicationLockExtension: onUnlockAttemptFailure"); if (lockOverlay != null) { // avoid race conditions updateLockOverlayAttempts(); @@ -173,7 +174,7 @@ private void onUnlockAttemptFailure() { } private void onUnlockAttemptSuccess() { - Crashlytics.logBreadcrumb("ApplicationLockExtension: onUnlockAttemptSuccess"); + Timber.i("ApplicationLockExtension: onUnlockAttemptSuccess"); if (lockOverlay != null) { // avoid race conditions lockOverlay.reportSuccess(); @@ -186,28 +187,29 @@ private class BoundLockOverlayListener implements MuunLockOverlay.LockOverlayLis @Override public void onPinEntered(String pin) { - Crashlytics.logBreadcrumb("ApplicationLockExtension: onPinEntered. " + this); + Timber.i("ApplicationLockExtension: onPinEntered. " + this); Single.fromCallable(() -> lockManager.tryUnlockWithPin(pin)) .compose(executionTransformerFactory.getSingleAsyncExecutor()) .subscribe(isUnlocked -> { if (isUnlocked) { onUnlockAttemptSuccess(); + } else { onUnlockAttemptFailure(); } }, throwable -> { + lockOverlay.reportError(null); + // Avoid crashes due to keystore's weird bugs. If it's a secure storage // error, catch it, otherwise re-throw it if (ExtensionsKt.isInstanceOrIsCausedBySecureStorageError(throwable)) { Timber.e(throwable); // Probably redundant, should already be logged - lockOverlay.reportError(null); } else if (throwable instanceof WeirdIncorrectAttemptsBugError) { // Attempt to log/track weird error we've seen in prd. Handle error will // show error report dialog and hopefully users will send it to us. Timber.e(throwable); - lockOverlay.reportError(null); ((BaseActivity) getActivity()).getPresenter().handleError(throwable); } else { @@ -216,19 +218,5 @@ public void onPinEntered(String pin) { } }); } - - @Override - public void onFingerprintEntered() { - lockManager.tryUnlockWithFingerprint(); - afterSuccessOrFailure(); - } - - private void afterSuccessOrFailure() { - if (lockManager.isLockSet()) { - onUnlockAttemptFailure(); - } else { - onUnlockAttemptSuccess(); - } - } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/BaseRequestExtension.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/BaseRequestExtension.kt index 1672c5a4..66ef9ac5 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/BaseRequestExtension.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/BaseRequestExtension.kt @@ -4,10 +4,10 @@ import android.os.Bundle import android.view.View import icepick.Bundler import icepick.State -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.serialization.SerializationUtils import io.muun.apollo.presentation.ui.base.ActivityExtension import io.muun.common.utils.Preconditions +import timber.log.Timber abstract class BaseRequestExtension : ActivityExtension() { @@ -27,7 +27,7 @@ abstract class BaseRequestExtension : ActivityExtension() { val globalRequestCode = uniqueRequestCode registerRequestFromCaller(request, globalRequestCode) - Crashlytics.logBreadcrumb( + Timber.i( """startActivityForResult: globalRequestCode: $globalRequestCode request.viewRequestCode: ${request.viewRequestCode} @@ -65,9 +65,10 @@ abstract class BaseRequestExtension : ActivityExtension() { class CallerRequest { // Making fields public for Jackson to de/serialize + @JvmField // Required to avoid proguard obfuscation var viewId = 0 - @JvmField + @JvmField // Required to avoid proguard obfuscation var viewRequestCode = 0 } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ExternalResultExtension.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ExternalResultExtension.kt index c71c7185..b2e2feed 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ExternalResultExtension.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ExternalResultExtension.kt @@ -3,10 +3,8 @@ package io.muun.apollo.presentation.ui.activity.extension import android.content.Intent import androidx.fragment.app.DialogFragment import icepick.State -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.presentation.ui.base.di.PerActivity import timber.log.Timber -import java.util.HashMap import javax.inject.Inject @PerActivity @@ -62,7 +60,7 @@ class ExternalResultExtension @Inject constructor() : BaseRequestExtension() { val request = pendingRequests[globalRequestCode] var view = findCaller(request, Caller::class.java) - Crashlytics.logBreadcrumb( + Timber.i( """onActivityResult: globalRequestCode: $globalRequestCode resultCode: $resultCode @@ -86,9 +84,7 @@ class ExternalResultExtension @Inject constructor() : BaseRequestExtension() { // We're hunting down a sneaky bug here. Let's log every useful piece data // We believe there’s some random, not deterministic issue that makes fragments ids // change or something and our findCaller mechanism falls short - Crashlytics.logBreadcrumb( - "View/Fragment Caller not found for onActivityResult." - ) + Timber.i("View/Fragment Caller not found for onActivityResult.") val fragments = activity.supportFragmentManager.fragments val fragmentIds = fragments.map { it.id } val fragmentNames = fragments.map { it.javaClass.simpleName } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ScreenshotBlockExtension.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ScreenshotBlockExtension.kt index ca13468e..ad0c23b2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ScreenshotBlockExtension.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/ScreenshotBlockExtension.kt @@ -3,9 +3,9 @@ package io.muun.apollo.presentation.ui.activity.extension import android.view.WindowManager import io.muun.apollo.BuildConfig import io.muun.apollo.data.external.Globals -import io.muun.apollo.data.logging.Crashlytics.logBreadcrumb import io.muun.apollo.presentation.ui.base.ActivityExtension import io.muun.apollo.presentation.ui.base.di.PerActivity +import timber.log.Timber import javax.inject.Inject @PerActivity @@ -13,14 +13,14 @@ class ScreenshotBlockExtension @Inject constructor() : ActivityExtension() { fun startBlockingScreenshots(caller: String) { if (BuildConfig.PRODUCTION && Globals.INSTANCE.isReleaseBuild) { - logBreadcrumb("blockscreenshots: $caller ${activity.javaClass.simpleName} START") + Timber.i("blockscreenshots: $caller ${activity.javaClass.simpleName} START") activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) // prevent screenshots } } fun stopBlockingScreenshots(caller: String) { if (BuildConfig.PRODUCTION && Globals.INSTANCE.isReleaseBuild) { - logBreadcrumb("blockscreenshots: $caller ${activity.javaClass.simpleName} STOP") + Timber.i("blockscreenshots: $caller ${activity.javaClass.simpleName} STOP") activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/SnackBarExtension.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/SnackBarExtension.java index c381f25c..c29681f7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/SnackBarExtension.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/SnackBarExtension.java @@ -24,7 +24,7 @@ public SnackBarExtension() { } /** - * Show a dismissable SnackBar of indefinite duration. + * Show a dismissible SnackBar of indefinite duration. */ public void showSnackBarIndefinite(int messageResId) { showSnackBarIndefinite(messageResId, true, null); @@ -33,7 +33,7 @@ public void showSnackBarIndefinite(int messageResId) { /** * Show a SnackBar of indefinite duration with a specific height. */ - public void showSnackBarIndefinite(int messageResId, boolean dismissable, Float height) { + public void showSnackBarIndefinite(int messageResId, boolean dismissible, Float height) { // TODO if a snackbar is showing (snackbar != null) should what should we do? // We are choosing to replace the previous one and log an error (it shouldn't happen) @@ -51,7 +51,7 @@ public void showSnackBarIndefinite(int messageResId, boolean dismissable, Float Snackbar.LENGTH_INDEFINITE ); - if (dismissable) { + if (dismissible) { snackbar.setAction(R.string.dismiss, v -> snackbar.dismiss()); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java index faffb828..65d1fd1b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java @@ -1,7 +1,6 @@ package io.muun.apollo.presentation.ui.base; import io.muun.apollo.R; -import io.muun.apollo.data.logging.Crashlytics; import io.muun.apollo.data.logging.LoggingContext; import io.muun.apollo.domain.action.permission.UpdateContactsPermissionStateAction; import io.muun.apollo.domain.action.permission.UpdateNotificationPermissionStateAction; @@ -136,6 +135,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { try { super.onCreate(savedInstanceState); + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onCreate"); + Icepick.restoreInstanceState(this, savedInstanceState); setUpLayout(); @@ -158,6 +159,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override @CallSuper protected void onDestroy() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onDestroy"); tearDownUi(); super.onDestroy(); } @@ -194,6 +196,7 @@ protected void setPresenterView() { @Override @CallSuper protected void onSaveInstanceState(Bundle outState) { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onSaveInstanceState"); super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); presenter.saveState(outState); @@ -203,6 +206,7 @@ protected void onSaveInstanceState(Bundle outState) { @CallSuper protected void onResume() { try { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onResume"); super.onResume(); LoggingContext.setLocale(ExtensionsKt.locale(this).toString()); if (blockScreenshots()) { @@ -237,6 +241,7 @@ protected void onResume() { @Override @CallSuper protected void onPause() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onPause"); super.onPause(); presenter.tearDown(); if (blockScreenshots()) { @@ -246,6 +251,7 @@ protected void onPause() { @Override public void onStop() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onStop"); super.onStop(); // Avoid leaving soft keyboard shown. When coming back to lock screen it may be left hanging // around. Every screen should handle showing it again on their onResume method. @@ -329,6 +335,7 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override public void onBackPressed() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onBackPressed"); if (!shouldIgnoreBackAndExit()) { super.onBackPressed(); } @@ -353,7 +360,7 @@ protected boolean shouldIgnoreBackAndExit() { // Tackling weird `Can't change activity type once set:` crash. See: // https://stackoverflow.com/questions/55005798/illegalstateexception-cant-change-activity-type-once-set - Crashlytics.logBreadcrumb("shouldIgnoreBackAndExit() failed: " + e.getMessage()); + Timber.i("shouldIgnoreBackAndExit() failed: " + e.getMessage()); Timber.e(e); // On this weird case we just ignore back (user can try pin again or choose to kill @@ -367,12 +374,13 @@ protected boolean shouldIgnoreBackAndExit() { @Deprecated // Use BaseActivity#finishActivity() instead @Override public void finish() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#finish"); super.finish(); } @Override public void finishActivity() { - Crashlytics.logBreadcrumb("Finishing Activity: " + getClass().getSimpleName()); + Timber.i("Finishing Activity: " + getClass().getSimpleName()); supportFinishAfterTransition(); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java index 888067c1..4a04bfd2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java @@ -5,6 +5,7 @@ import io.muun.apollo.presentation.app.di.ApplicationComponent; import io.muun.apollo.presentation.ui.activity.extension.ExternalResultExtension.Caller; import io.muun.apollo.presentation.ui.activity.extension.MuunDialog; +import io.muun.apollo.presentation.ui.activity.extension.PermissionManagerExtension; import io.muun.apollo.presentation.ui.activity.extension.PermissionManagerExtension.PermissionRequester; import io.muun.apollo.presentation.ui.base.di.FragmentComponent; import io.muun.apollo.presentation.ui.utils.UiUtils; @@ -141,6 +142,7 @@ public Bundle getArgumentsBundle() { @Override @CallSuper public void onResume() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onResume"); super.onResume(); presenter.setUp(getArgumentsBundle()); presenter.afterSetUp(); @@ -149,6 +151,7 @@ public void onResume() { @Override @CallSuper public void onPause() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onStop"); super.onPause(); presenter.tearDown(); } @@ -170,6 +173,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat @Override @CallSuper public void onDestroyView() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onDestroyView"); butterKnifeUnbinder.unbind(); tearDownUi(); super.onDestroyView(); @@ -177,6 +181,7 @@ public void onDestroyView() { @Override public void onStart() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onStart"); super.onStart(); if (blockScreenshots()) { @@ -199,6 +204,7 @@ public void onStart() { @Override public void onStop() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#onStop"); super.onStop(); if (blockScreenshots()) { final String caller = this.getClass().getSimpleName(); diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/ExtensibleActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/ExtensibleActivity.java index 67ac0b40..0f22fad1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/ExtensibleActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/ExtensibleActivity.java @@ -17,7 +17,7 @@ public abstract class ExtensibleActivity extends AppCompatActivity { - private List extensions = new ArrayList<>(); + private final List extensions = new ArrayList<>(); protected abstract void setUpExtensions(); diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivityImpl.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivityImpl.kt index 5cf2094e..a183036b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivityImpl.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivityImpl.kt @@ -4,8 +4,8 @@ import android.content.Context import android.content.Intent import butterknife.BindView import io.muun.apollo.R -import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.presentation.ui.view.MuunHeader +import timber.log.Timber class SingleFragmentActivityImpl : SingleFragmentActivity>() { @@ -18,7 +18,7 @@ class SingleFragmentActivityImpl : fragment: Class>, ): Intent { - Crashlytics.logBreadcrumb("SingleFragmentActivityImpl: startActivityIntent $fragment") + Timber.i("SingleFragmentActivityImpl: startActivityIntent $fragment") val intent = Intent(context, SingleFragmentActivityImpl::class.java) intent.putExtra(FRAGMENT_CLASS, fragment) @@ -51,7 +51,7 @@ class SingleFragmentActivityImpl : val fragmentClass: Class<*> = argumentsBundle.getSerializable(FRAGMENT_CLASS) as Class<*> - Crashlytics.logBreadcrumb("SingleFragmentActivityImpl: getInitialFragment $fragmentClass") + Timber.i("SingleFragmentActivityImpl: getInitialFragment $fragmentClass") try { return fragmentClass.getConstructor().newInstance() as BaseFragment> diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.java index 224af456..09c40de6 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.java @@ -2,19 +2,18 @@ import io.muun.apollo.domain.action.ContactActions; import io.muun.apollo.domain.action.OperationActions; -import io.muun.apollo.domain.action.address.CreateAddressAction; import io.muun.apollo.domain.action.address.SyncExternalAddressIndexesAction; -import io.muun.apollo.domain.action.incoming_swap.GenerateInvoiceAction; import io.muun.apollo.domain.action.integrity.IntegrityAction; import io.muun.apollo.domain.action.realtime.FetchRealTimeDataAction; -import io.muun.apollo.domain.selector.UserPreferencesSelector; +import io.muun.apollo.domain.debug.DebugExecutable; +import io.muun.apollo.domain.errors.debug.DebugExecutableError; import io.muun.apollo.presentation.ui.base.BasePresenter; import io.muun.apollo.presentation.ui.base.BaseView; import io.muun.apollo.presentation.ui.base.di.PerActivity; -import io.muun.common.crypto.hd.MuunAddress; import rx.Observable; -import rx.functions.Action0; +import rx.functions.Actions; +import timber.log.Timber; import javax.inject.Inject; @@ -27,9 +26,7 @@ public class DebugPanelPresenter extends BasePresenter { private final FetchRealTimeDataAction fetchRealTimeData; private final SyncExternalAddressIndexesAction syncExternalAddressIndexes; - private final CreateAddressAction createAddress; - private final GenerateInvoiceAction generateInvoice; - private final UserPreferencesSelector userPreferencesSel; + private final DebugExecutable debugExecutable; /** * Creates a presenter. @@ -41,18 +38,15 @@ public DebugPanelPresenter( IntegrityAction integrityAction, SyncExternalAddressIndexesAction syncExternalAddressIndexes, FetchRealTimeDataAction fetchRealTimeData, - CreateAddressAction createAddressAction, - GenerateInvoiceAction generateInvoiceAction, - UserPreferencesSelector userPreferencesSel) { + DebugExecutable debugExecutable + ) { this.operationActions = operationActions; this.contactActions = contactActions; this.integrityAction = integrityAction; this.syncExternalAddressIndexes = syncExternalAddressIndexes; this.fetchRealTimeData = fetchRealTimeData; - this.createAddress = createAddressAction; - this.generateInvoice = generateInvoiceAction; - this.userPreferencesSel = userPreferencesSel; + this.debugExecutable = debugExecutable; } /** @@ -152,60 +146,57 @@ public void updateFcmToken() { * Use Lapp client to send btc to this wallet. Only to be used in Regtest or local build. */ public void fundThisWalletOnChain() { - doInBackground(() -> { - final MuunAddress segwitAddress = createAddress.actionNow().getSegwit(); - new LappClient().receiveBtc(0.4, segwitAddress.getAddress()); - }); + debugExecutable.fundWalletOnChain() + .subscribe(Actions.empty(), this::handleError); } /** * Use Lapp client to send btc to this wallet via LN. Only to be used in Regtest or local build. */ public void fundThisWalletOffChain() { - doInBackground(() -> { - final long amountInSats = 11000L; - final String invoice = generateInvoice.actionNow(amountInSats); - final boolean turboChannels = !userPreferencesSel.get().getStrictMode(); - new LappClient().receiveBtcViaLN(invoice, amountInSats, turboChannels); - }); + debugExecutable.fundWalletOffChain() + .subscribe(Actions.empty(), this::handleError); } /** * Use Lapp client to generate a block. Only to be used in Regtest or local builds. */ public void generateBlock() { - doInBackground(() -> new LappClient().generateBlocks(1)); + debugExecutable.generateBlock() + .subscribe(Actions.empty(), this::handleError); } /** * Use Lapp client to drop last tx. Only to be used in Regtest or local builds. */ public void dropLastTxFromMempool() { - doInBackground(() -> new LappClient().dropLastTxFromMempool()); + debugExecutable.dropLastTxFromMempool() + .subscribe(Actions.empty(), this::handleError); } /** * Use Lapp client to drop last tx. Only to be used in Regtest or local builds. */ public void dropTx(String txId) { - doInBackground(() -> new LappClient().dropTx(txId)); + debugExecutable.dropTx(txId) + .subscribe(Actions.empty(), this::handleError); } /** * Use Lapp client to undrop last tx. Only to be used in Regtest or local builds. */ public void undropTx(String txId) { - doInBackground(() -> new LappClient().undropTx(txId)); + debugExecutable.undropTx(txId) + .subscribe(Actions.empty(), this::handleError); } - private void doInBackground(Action0 action) { - final Observable observable = Observable.defer(() -> { - action.call(); - return Observable.just(null); - }); - - observable - .compose(transformerFactory.getAsyncExecutor()) - .subscribe(); + @Override + public void handleError(Throwable error) { + if (error instanceof DebugExecutableError) { + Timber.e(error); + view.showTextToast(error.getMessage()); + } else { + super.handleError(error); + } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java index 6edfc09d..ab4a5b07 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java @@ -52,10 +52,9 @@ public static Intent getStartActivityIntent(@NotNull Context context) { return new Intent(context, HomeActivity.class); } - public static String SHOW_WELCOME_TO_MUUN = "SHOW_WELCOME_TO_MUUN"; + static String SHOW_WELCOME_TO_MUUN = "SHOW_WELCOME_TO_MUUN"; public static String NEW_OP_ID = "NEW_OP_ID"; - @BindView(R.id.home_header) MuunHeader header; @@ -120,18 +119,6 @@ protected void initializeUi() { bottomNav.setOnItemReselectedListener(item -> { // do nothing here, it will prevent recreating same fragment }); - - if (getIntent().hasExtra(SHOW_WELCOME_TO_MUUN)) { - getIntent().removeExtra(SHOW_WELCOME_TO_MUUN); - - final MuunDialog muunDialog = new MuunDialog.Builder() - .layout(R.layout.dialog_welcome_to_muun) - .style(R.style.MuunWelcomeDialog) - .addOnClickAction(R.id.welcome_to_muun_cta, v -> dismissDialog()) - .build(); - - showDialog(muunDialog); - } } @Override @@ -163,6 +150,17 @@ public void navigateToSecurityCenter() { navigateToItem(R.id.security_center_fragment, args.toBundle()); } + @Override + public void showWelcomeToMuunDialog() { + final MuunDialog muunDialog = new MuunDialog.Builder() + .layout(R.layout.dialog_welcome_to_muun) + .style(R.style.MuunWelcomeDialog) + .addOnClickAction(R.id.welcome_to_muun_cta, v -> dismissDialog()) + .build(); + + showDialog(muunDialog); + } + /** * Show Taproot celebration dialog! A once-in-a-lifetime special event. */ diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomePresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomePresenter.java index 845698dd..c4601558 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomePresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomePresenter.java @@ -2,6 +2,7 @@ import io.muun.apollo.data.async.tasks.TaskScheduler; import io.muun.apollo.domain.LoggingContextManager; +import io.muun.apollo.domain.ShowWelcomeToMuunManager; import io.muun.apollo.domain.SignupDraftManager; import io.muun.apollo.domain.action.ContactActions; import io.muun.apollo.domain.action.NotificationActions; @@ -29,6 +30,8 @@ import javax.money.Monetary; import javax.validation.constraints.NotNull; +import static io.muun.apollo.presentation.ui.home.HomeActivity.SHOW_WELCOME_TO_MUUN; + @PerActivity public class HomePresenter extends BasePresenter implements HomeFragmentParentPresenter { @@ -37,6 +40,7 @@ public class HomePresenter extends BasePresenter implements HomeFragme private final NotificationActions notificationActions; private final UserSelector userSel; private final UserActivatedFeatureStatusSelector userActivatedFeatureStatusSel; + private final ShowWelcomeToMuunManager showWelcomeToMuun; private final SignupDraftManager signupDraftManager; private final TaskScheduler taskScheduler; @@ -59,6 +63,7 @@ public HomePresenter(LoggingContextManager loggingContextManager, NotificationActions notificationActions, UserSelector userSel, UserActivatedFeatureStatusSelector userActivatedFeatureStatusSel, + ShowWelcomeToMuunManager showWelcomeToMuunManager, SignupDraftManager signupDraftManager, TaskScheduler taskScheduler, FetchRealTimeDataAction fetchRealTimeData, @@ -68,6 +73,7 @@ public HomePresenter(LoggingContextManager loggingContextManager, this.contactActions = contactActions; this.userSel = userSel; this.userActivatedFeatureStatusSel = userActivatedFeatureStatusSel; + this.showWelcomeToMuun = showWelcomeToMuunManager; this.signupDraftManager = signupDraftManager; this.fetchRealTimeData = fetchRealTimeData; this.notificationActions = notificationActions; @@ -92,11 +98,15 @@ public void onViewCreated(Bundle savedInstanceState) { fetchRealTimeData.runForced(); + if (shouldShowWelcomeToMuun()) { + showWelcomeToMuun.setSeen(); + view.showWelcomeToMuunDialog(); + } + new PdfFontIssueTracker(getContext(), analytics) .track(AnalyticsEvent.PDF_FONT_ISSUE_TYPE.HOME_VIEW); } - /** * Call to report activity was destroyed. */ @@ -178,4 +188,17 @@ public void navigateToSendFeedbackScreen() { public void reportTaprootCelebrationShown() { userSel.setPendingTaprootCelebration(false); } + + private boolean shouldShowWelcomeToMuun() { + // We use SHOW_WELCOME_TO_MUUN Intent extra to mark the HomeActivity opening when we've just + // created a new wallet but we also need the help of a user preference because even though + // it's possible to remove extras from an Intent there are on some scenarios like activity + // recreation where the activity is passed the original Intent, unchanged. Which results in + // the welcome dialog being shown more than once (e.g if activity is recreated). Apparently, + // the original Intent is actually immutable and its stored somewhere in Android's + // ActivityManager. + // For more info check out: https://stackoverflow.com/a/41574485/901465 + final boolean hasSeenWelcomeToMuun = showWelcomeToMuun.getSeen(); + return view.getArgumentsBundle().getBoolean(SHOW_WELCOME_TO_MUUN) && !hasSeenWelcomeToMuun; + } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeView.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeView.java index a64bd4fa..5a68c3e6 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeView.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeView.java @@ -4,7 +4,18 @@ public interface HomeView extends BaseView { + /** + * Takes user to SecurityCenter screen. + */ void navigateToSecurityCenter(); + /** + * Takes user to SecurityCenter screen. + */ + void showWelcomeToMuunDialog(); + + /** + * Show Taproot celebration. + */ void showTaprootCelebration(); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawActivity.kt index 02081b67..e942304c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawActivity.kt @@ -141,9 +141,10 @@ class LnUrlWithdrawActivity: SingleFragmentActivity(), L // Sometimes this error is triggered super quickly on retries, making the UI kinda // glitchy, jumping from one state to another. So, we add a small delay to avoid // glitches on retries - postDelayed(3000) { + postDelayed(1000) { showError(error.asViewModel(this)) } + return } showError(error.asViewModel(this)) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawPresenter.kt index 07669a76..d23aa303 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/lnurl/withdraw/LnUrlWithdrawPresenter.kt @@ -23,8 +23,8 @@ class LnUrlWithdrawPresenter @Inject constructor( private val lnUrlWithdrawAction: LnUrlWithdrawAction, private val waitForIncomingLnPaymentSel: WaitForIncomingLnPaymentSelector, private val notificationService: NotificationService, - private val notificationPoller: UiNotificationPoller -): BasePresenter() { + private val notificationPoller: UiNotificationPoller, +) : BasePresenter() { @State(LnUrlWithdrawErrorBundler::class) @JvmField @@ -45,7 +45,7 @@ class LnUrlWithdrawPresenter @Inject constructor( val observable: Observable = lnUrlWithdrawAction .state - .compose(handleStates(null, this::handleError)) + .compose(handleStates(null, this::handleError)) .doOnNext { state -> handleWithdrawState(state) } @@ -146,13 +146,14 @@ class LnUrlWithdrawPresenter @Inject constructor( fun handleErrorDescriptionClicked() { // Assigning to a local variable makes error "inmutable" to kotlin and allows smart casts - val error = error - when { - error == null -> {} - error is LnUrlError.ExpiredInvoice -> { + when (error) { + is LnUrlError.ExpiredInvoice -> { clipboardManager.copy("LNURL Expired Invoice", lnUrlWithdraw.invoice) view.showTextToast(context.getString(R.string.operation_detail_invoice_copied)) } + else -> { + // Do Nothing + } } } @@ -172,7 +173,7 @@ class LnUrlWithdrawPresenter @Inject constructor( } private fun eventType(state: LnUrlState): AnalyticsEvent.LNURL_WITHDRAW_STATE_TYPE = - when(state) { + when (state) { is LnUrlState.Contacting -> AnalyticsEvent.LNURL_WITHDRAW_STATE_TYPE.CONTACTING is LnUrlState.InvoiceCreated -> AnalyticsEvent.LNURL_WITHDRAW_STATE_TYPE.INVOICE_CREATED is LnUrlState.Receiving -> AnalyticsEvent.LNURL_WITHDRAW_STATE_TYPE.RECEIVING diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmount.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmount.kt index 46434bcd..32dec8f5 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmount.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmount.kt @@ -2,18 +2,86 @@ package io.muun.apollo.presentation.ui.new_operation import io.muun.apollo.domain.model.BitcoinAmount import io.muun.apollo.domain.model.BitcoinUnit +import io.muun.apollo.presentation.ui.helper.isBtc +import io.muun.common.utils.BitcoinUtils +import timber.log.Timber +import javax.money.MonetaryAmount class DisplayAmount( val amount: BitcoinAmount, - val bitcoinUnit: BitcoinUnit, - val isValid: Boolean = true + private val bitcoinUnit: BitcoinUnit, + private val isSatSelectedAsCurrency: Boolean, + val isValid: Boolean = true, ) { constructor( amt: newop.BitcoinAmount, btcUnit: BitcoinUnit, - isValid: Boolean = true + isSatSelectedAsCurrency: Boolean, + isValid: Boolean = true, ) : this( - BitcoinAmount.fromLibwallet(amt), btcUnit, isValid + BitcoinAmount.fromLibwallet(amt), btcUnit, isSatSelectedAsCurrency, isValid ) + + /** + * Cyclically rotate currencies of a DisplayAmount. Following these rules: + * + * - input -> primary (unless input == primary, then btc) + * - primary -> BTC (unless primary == BTC, then input) + * - BTC -> input + */ + fun rotateCurrency(selectedCurrencyCode: String): MonetaryAmount { + + val inputCurrency = amount.inInputCurrency.currency.currencyCode + val primaryCurrency = amount.inPrimaryCurrency.currency.currencyCode + + val selectedCurrency = if (selectedCurrencyCode == "SAT") { + "BTC" + } else { + selectedCurrencyCode + } + + @Suppress("CascadeIf") + if (selectedCurrency == inputCurrency) { + return if (selectedCurrency == primaryCurrency) { + BitcoinUtils.satoshisToBitcoins(amount.inSatoshis) + } else { + amount.inPrimaryCurrency + } + + } else if (selectedCurrency == primaryCurrency) { + return if (selectedCurrency == "BTC") { + amount.inInputCurrency + } else { + BitcoinUtils.satoshisToBitcoins(amount.inSatoshis) + } + + } else if (selectedCurrency == "BTC") { + return amount.inInputCurrency // We already know selectedCurrency != inputCurrency + } + + Timber.i("Bug in rotateCurrency(). $selectedCurrencyCode, $inputCurrency, $primaryCurrency") + return BitcoinUtils.satoshisToBitcoins(amount.inSatoshis) // Shouldn't really happen + } + + /** + * Part of our (ugly) hack to allow SATs as an input currency option. Which should now be + * contained just here and inside MuunAmountInput. + * This method answers the question of which BitcoinUnit should be used to format/display btc + * amounts in a flow where SAT can be chosen as a currency. Usually we would just use the + * BitcoinUnit User preference, but when on a flowgit where SAT is chosen as a currency (e.g new + * operation flow) we override that preference. Following the same logic, if the chosen + * currency is BTC, but the user bitcoin unit preference is SAT, we prioritize the chosen + * currency. + * TODO should we try to encapsulate this INSIDE this class and avoid exposing this method? + */ + fun getBitcoinUnit(): BitcoinUnit = if (isSatSelectedAsCurrency) { + BitcoinUnit.SATS + } else { + if (amount.inInputCurrency.currency.isBtc()) { + BitcoinUnit.BTC + } else { + bitcoinUnit + } + } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt index 13cb1913..3a8b7bf3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt @@ -14,7 +14,6 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import butterknife.BindView import butterknife.BindViews -import icepick.State import io.muun.apollo.R import io.muun.apollo.domain.analytics.NewOperationOrigin import io.muun.apollo.domain.libwallet.adapt @@ -27,7 +26,6 @@ import io.muun.apollo.domain.model.OperationUri import io.muun.apollo.domain.model.PaymentRequest import io.muun.apollo.domain.model.SubmarineSwap import io.muun.apollo.domain.model.SubmarineSwapReceiver -import io.muun.apollo.domain.utils.locale import io.muun.apollo.presentation.ui.MuunCountdownTimer import io.muun.apollo.presentation.ui.activity.extension.MuunDialog import io.muun.apollo.presentation.ui.base.SingleFragmentActivity @@ -35,7 +33,6 @@ import io.muun.apollo.presentation.ui.base.di.PerActivity import io.muun.apollo.presentation.ui.fragments.manual_fee.ManualFeeFragment import io.muun.apollo.presentation.ui.fragments.new_op_error.NewOperationErrorFragment import io.muun.apollo.presentation.ui.fragments.recommended_fee.RecommendedFeeFragment -import io.muun.apollo.presentation.ui.helper.MoneyHelper import io.muun.apollo.presentation.ui.home.HomeActivity import io.muun.apollo.presentation.ui.listener.SimpleTextWatcher import io.muun.apollo.presentation.ui.new_operation.NewOperationView.Receiver @@ -54,7 +51,6 @@ import io.muun.apollo.presentation.ui.view.RichText import io.muun.apollo.presentation.ui.view.StatusMessage import io.muun.apollo.presentation.ui.view.TextInputWithBackHandling import io.muun.common.exception.MissingCaseError -import io.muun.common.utils.BitcoinUtils import newop.EnterAmountState import newop.EnterDescriptionState import newop.PaymentIntent @@ -183,12 +179,6 @@ class NewOperationActivity : SingleFragmentActivity(), Ne @BindView(R.id.button_layout_anchor) lateinit var buttonLayoutAnchor: View - // State: - - @State - @JvmField - var displayInAlternateCurrency: Boolean = false - private var countdownTimer: MuunCountdownTimer? = null override fun inject() { @@ -311,12 +301,6 @@ class NewOperationActivity : SingleFragmentActivity(), Ne updateReceiver(state.resolved.paymentIntent, receiver) val balance = BitcoinAmount.fromLibwallet(state.totalBalance) - val balanceText = MoneyHelper.formatLongMonetaryAmount( - balance.inInputCurrency, - true, - amountInput.bitcoinUnit, - applicationContext.locale() - ) val newAmount = state.amount.inInputCurrency.adapt() amountInput.isEnabled = true @@ -326,7 +310,10 @@ class NewOperationActivity : SingleFragmentActivity(), Ne // TODO: change state machine changeCurrency API amountInput.value = newAmount } - amountInput.setSecondaryAmount("${getString(R.string.available_balance)}: $balanceText") + amountInput.setSecondaryAmount( + "${getString(R.string.available_balance)}: %s", + balance.inInputCurrency + ) root.layoutTransition = null showLoadingSpinner(false) @@ -367,14 +354,22 @@ class NewOperationActivity : SingleFragmentActivity(), Ne actionButton.isEnabled = false } - override fun goToEnterDescriptionState(state: EnterDescriptionState, receiver: Receiver) { + override fun goToEnterDescriptionState( + state: EnterDescriptionState, + receiver: Receiver, + btcUnit: BitcoinUnit, + ) { updateReceiver(state.resolved.paymentIntent, receiver) show1ConfNotice(state.validated.swapInfo?.isOneConf ?: false) val isValid = !state.validated.feeNeedsChange - setAmount(selectedAmount, DisplayAmount(state.amountInfo.amount, getBitcoinUnit(), isValid)) + val isSatSelectedAsCurrency = amountInput.isSatSelectedAsCurrency + setAmount( + selectedAmount, + DisplayAmount(state.amountInfo.amount, btcUnit, isSatSelectedAsCurrency, isValid) + ) descriptionInput.setText(state.note) @@ -418,16 +413,30 @@ class NewOperationActivity : SingleFragmentActivity(), Ne statusMessageViews.changeVisibility(View.GONE) } - override fun goToConfirmState(state: ConfirmStateViewModel, receiver: Receiver) { + override fun goToConfirmState( + state: ConfirmStateViewModel, + receiver: Receiver, + btcUnit: BitcoinUnit, + ) { updateReceiver(state.paymentIntent, receiver) show1ConfNotice(state.validated.swapInfo?.isOneConf ?: false) val isValid = !state.validated.feeNeedsChange - setAmount(selectedAmount, DisplayAmount(state.amountInfo.amount, getBitcoinUnit(), isValid)) - setAmount(feeAmount, DisplayAmount(state.validated.fee, getBitcoinUnit(), isValid)) - setAmount(totalAmount, DisplayAmount(state.validated.total, getBitcoinUnit(), isValid)) + val isSatSelectedAsCurrency = amountInput.isSatSelectedAsCurrency + setAmount( + selectedAmount, + DisplayAmount(state.amountInfo.amount, btcUnit, isSatSelectedAsCurrency, isValid) + ) + setAmount( + feeAmount, + DisplayAmount(state.validated.fee, btcUnit, isSatSelectedAsCurrency, isValid) + ) + setAmount( + totalAmount, + DisplayAmount(state.validated.total, btcUnit, isSatSelectedAsCurrency, isValid) + ) descriptionContent.text = state.note @@ -553,7 +562,6 @@ class NewOperationActivity : SingleFragmentActivity(), Ne } private fun handleToggleCurrencyChange() { - displayInAlternateCurrency = !displayInAlternateCurrency toggleCurrencyChange(selectedAmount) toggleCurrencyChange(feeAmount) toggleCurrencyChange(totalAmount) @@ -563,22 +571,11 @@ class NewOperationActivity : SingleFragmentActivity(), Ne val displayAmt: DisplayAmount? = view.tag as? DisplayAmount if (displayAmt != null) { // If view isn't being show yet, we do nothing - val amountToDisplay = if (displayInAlternateCurrency) { - - val (_, currencyCode) = view.text.toString().split(" ") + val (_, selectedCurrencyCode) = view.text.toString().split(" ") - // Show BTC if current display is in FIAT, and the other way around. - if (currencyCode == "BTC" || currencyCode == "SAT") { - displayAmt.amount.inPrimaryCurrency - } else { - BitcoinUtils.satoshisToBitcoins(displayAmt.amount.inSatoshis) - } - } else { - // Show the amount as it originally was. - displayAmt.amount.inInputCurrency - } + val amountToDisplay = displayAmt.rotateCurrency(selectedCurrencyCode) - view.text = toRichText(amountToDisplay, displayAmt.bitcoinUnit, displayAmt.isValid) + view.text = toRichText(amountToDisplay, displayAmt.getBitcoinUnit(), displayAmt.isValid) } } @@ -696,7 +693,7 @@ class NewOperationActivity : SingleFragmentActivity(), Ne private fun setAmount(view: TextView, displayAmount: DisplayAmount) { val amount = displayAmount.amount.inInputCurrency - view.text = toRichText(amount, displayAmount.bitcoinUnit, displayAmount.isValid) + view.text = toRichText(amount, displayAmount.getBitcoinUnit(), displayAmount.isValid) view.tag = displayAmount } @@ -768,8 +765,4 @@ class NewOperationActivity : SingleFragmentActivity(), Ne } } } - - // Part of our (ugly) hack to allow SATs as an input currency option - private fun getBitcoinUnit(): BitcoinUnit = - amountInput.bitcoinUnit } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationForm.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationForm.java deleted file mode 100644 index 5a297a7e..00000000 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationForm.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.muun.apollo.presentation.ui.new_operation; - -import io.muun.apollo.domain.model.OperationUri; -import io.muun.apollo.domain.model.PaymentRequest; - -import org.javamoney.moneta.Money; - -import javax.annotation.Nullable; -import javax.money.MonetaryAmount; -import javax.validation.constraints.NotNull; - - -public class NewOperationForm { - - @NotNull - final OperationUri operationUri; - - @Nullable // during resolve - public PaymentRequest payReq; - - @Nullable // Set once the user presses submit with the definite pay req used - public PaymentRequest submitedPayReq; - - @NotNull - public MonetaryAmount amount; - public boolean isAmountFixed; - public boolean isAmountConfirmed; - public boolean isUsingAllFunds; - - @NotNull - public String description; - public boolean isDescriptionConfirmed; - - public Double selectedFeeRate; - public boolean isFeeFixed; - - public boolean displayInAlternateCurrency; - - /** - * Constructor with defaults. - */ - public NewOperationForm(OperationUri operationUri) { - this.amount = Money.of(0, "BTC"); - this.description = ""; - this.operationUri = operationUri; - } -} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt index 02472eeb..c5681824 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt @@ -551,7 +551,7 @@ class NewOperationPresenter @Inject constructor( "update" to state.update ) ) - view.goToEnterDescriptionState(state, receiver) + view.goToEnterDescriptionState(state, receiver, bitcoinUnitSel.get()) } private fun handleValidateState(state: ValidateState) { @@ -574,7 +574,7 @@ class NewOperationPresenter @Inject constructor( ) ) - view.goToConfirmState(confirmStateViewModel, receiver) + view.goToConfirmState(confirmStateViewModel, receiver, bitcoinUnitSel.get()) } private fun handleConfirmLightningState(state: ConfirmLightningState) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationView.kt index cb1dd7ba..5811ae90 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationView.kt @@ -21,9 +21,13 @@ interface NewOperationView : BaseView { fun setAmountInputError() - fun goToEnterDescriptionState(state: EnterDescriptionState, receiver: Receiver) + fun goToEnterDescriptionState( + state: EnterDescriptionState, + receiver: Receiver, + btcUnit: BitcoinUnit, + ) - fun goToConfirmState(state: ConfirmStateViewModel, receiver: Receiver) + fun goToConfirmState(state: ConfirmStateViewModel, receiver: Receiver, btcUnit: BitcoinUnit) fun goToEditFeeState() diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrActivity.java index 7f30ade0..db5ff633 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrActivity.java @@ -6,6 +6,7 @@ import io.muun.apollo.presentation.ui.base.SingleFragmentActivity; import io.muun.apollo.presentation.ui.fragments.error.ErrorViewModel; import io.muun.apollo.presentation.ui.utils.ExtensionsKt; +import io.muun.apollo.presentation.ui.utils.OS; import io.muun.apollo.presentation.ui.utils.UiUtils; import io.muun.apollo.presentation.ui.view.MuunEmptyScreen; import io.muun.apollo.presentation.ui.view.MuunHeader; @@ -70,6 +71,11 @@ public static Intent getStartActivityIntentForLnurl(@NotNull Context context, @BindView(R.id.uri_paster) MuunUriPaster uriPaster; + @BindView(R.id.paste_from_clipboard) + View pasteFromClipboard; + + private boolean clipboardContainsPlaintext = false; + @Override protected void inject() { getComponent().inject(this); @@ -106,15 +112,29 @@ protected void initializeUi() { emptyScreen.setOnActionClickListener(view -> onGrantPermissionClick()); if (allPermissionsGranted(Manifest.permission.CAMERA)) { - background.setVisibility(View.VISIBLE); - subtitle.setVisibility(View.VISIBLE); + showScannerView(); } else { - background.setVisibility(View.GONE); - subtitle.setVisibility(View.GONE); + showCameraPermissionPriming(); } setupCamera(); + + pasteFromClipboard.setOnClickListener(v -> presenter.pasteFromClipboard()); + } + + private void showScannerView() { + background.setVisibility(View.VISIBLE); + subtitle.setVisibility(View.VISIBLE); + if (OS.supportsClipboardAccessNotification() && clipboardContainsPlaintext) { + pasteFromClipboard.setVisibility(View.VISIBLE); + } + } + + private void showCameraPermissionPriming() { + background.setVisibility(View.GONE); + subtitle.setVisibility(View.GONE); + pasteFromClipboard.setVisibility(View.GONE); } private void setupCamera() { @@ -238,14 +258,12 @@ public void onGrantPermissionClick() { @Override public void onPermissionsDenied(String[] deniedPermissions) { - background.setVisibility(View.GONE); - subtitle.setVisibility(View.GONE); + showCameraPermissionPriming(); } @Override public void onPermissionsGranted(String[] grantedPermissions) { - background.setVisibility(View.VISIBLE); - subtitle.setVisibility(View.VISIBLE); + showScannerView(); presenter.reportCameraPermissionGranted(); if (uriPaster.getUri() != null) { @@ -253,6 +271,17 @@ public void onPermissionsGranted(String[] grantedPermissions) { } } + @Override + public void setClipboardStatus(boolean containsPlainText) { + this.clipboardContainsPlaintext = containsPlainText; + + if (allPermissionsGranted(Manifest.permission.CAMERA)) { + if (OS.supportsClipboardAccessNotification() && clipboardContainsPlaintext) { + pasteFromClipboard.setVisibility(View.VISIBLE); + } + } + } + @Override public void setClipboardUri(@Nullable OperationUri operationUri) { uriPaster.setUri(operationUri); @@ -260,4 +289,9 @@ public void setClipboardUri(@Nullable OperationUri operationUri) { uriPaster.setVisibility(View.GONE); } } + + @Override + public void showNoLnUrlInClipoardError() { + showSnackBar(R.string.no_lnurl_in_clipboard_error); + } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrPresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrPresenter.java index 13d041cb..a79050df 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrPresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrPresenter.java @@ -7,6 +7,7 @@ import io.muun.apollo.domain.selector.ClipboardUriSelector; import io.muun.apollo.presentation.ui.base.BasePresenter; import io.muun.apollo.presentation.ui.base.di.PerActivity; +import io.muun.apollo.presentation.ui.utils.OS; import android.os.Bundle; import icepick.State; @@ -49,12 +50,23 @@ public void setUp(Bundle arguments) { } private void setUpClipboard() { - final Observable observable = clipboardUriSel - .watch() - .compose(getAsyncExecutor()) - .doOnNext(this::setClipboardUri); + if (!OS.supportsClipboardAccessNotification()) { + final Observable observable = clipboardUriSel + .watch() + .compose(getAsyncExecutor()) + .doOnNext(this::setClipboardUri); - subscribeTo(observable); + subscribeTo(observable); + + } else { + final Observable observable = clipboardManager + .watchForPlainText() + .compose(getAsyncExecutor()) + .doOnNext(view::setClipboardStatus); + + subscribeTo(observable); + + } } private void setClipboardUri(OperationUri operationUri) { @@ -144,6 +156,7 @@ protected AnalyticsEvent getEntryEvent() { public Unit selectFromUriPaster(@NotNull OperationUri uri) { if (uri.getLnUrl().isPresent()) { navigator.navigateToLnUrlWithdraw(getContext(), uri.getLnUrl().get()); + } else { Timber.e(new RuntimeException("Non-LNURL Uri in LNURL UriPaster. Should not happen!")); } @@ -151,4 +164,24 @@ public Unit selectFromUriPaster(@NotNull OperationUri uri) { view.finishActivity(); return null; } + + /** + * Access clipboard and try to navigating to LNURL Withdraw screen, provided clipboard contains + * a LNURL. + */ + public void pasteFromClipboard() { + try { + final OperationUri operationUri = OperationUri.fromString(clipboardUriSel.getText()); + + if (operationUri.getLnUrl().isPresent()) { + navigator.navigateToLnUrlWithdraw(getContext(), operationUri.getLnUrl().get()); + + } else { + view.showNoLnUrlInClipoardError(); + } + + } catch (IllegalArgumentException e) { + view.showNoLnUrlInClipoardError(); + } + } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrView.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrView.java index 189c47e5..b5bf46f1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrView.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/scan_qr/ScanQrView.java @@ -7,9 +7,30 @@ public interface ScanQrView extends BaseView { String ARG_LNURL_FLOW = "lnurl_flow"; + /** + * Callback called when an error occurred during scan. Invalid scanned text is provided. + */ void onScanError(String text); + /** + * Callback called when an error occurred during an LNURL scan. Invalid scanned text is + * provided. + */ void onLnUrlScanError(String text); + /** + * Set whether clipboard contains plaintext content or not. + */ + void setClipboardStatus(boolean containsPlainText); + + /** + * Set OperationUri obtained from clipboard. + * Note: DEPRECATED, starting 12+ this is no longer considered good UX. + */ void setClipboardUri(OperationUri operationUri); + + /** + * Display an error indicating that clipboard content is not an LNURL. + */ + void showNoLnUrlInClipoardError(); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/select_amount/SelectAmountActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/select_amount/SelectAmountActivity.kt index 049db21f..ed3329ac 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/select_amount/SelectAmountActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/select_amount/SelectAmountActivity.kt @@ -5,14 +5,11 @@ import android.content.Intent import android.os.Bundle import butterknife.BindView import butterknife.OnClick -import icepick.State import io.muun.apollo.R import io.muun.apollo.data.serialization.SerializationUtils import io.muun.apollo.domain.model.BitcoinAmount import io.muun.apollo.domain.model.BitcoinUnit -import io.muun.apollo.domain.utils.locale import io.muun.apollo.presentation.ui.base.BaseActivity -import io.muun.apollo.presentation.ui.helper.MoneyHelper import io.muun.apollo.presentation.ui.helper.isBtc import io.muun.apollo.presentation.ui.helper.serialize import io.muun.apollo.presentation.ui.view.MuunAmountInput @@ -138,14 +135,7 @@ class SelectAmountActivity : BaseActivity(), SelectAmount } override fun setSecondaryAmount(amount: MonetaryAmount) { - amountInput.setSecondaryAmount( - MoneyHelper.formatLongMonetaryAmount( - amount, - true, - amountInput.bitcoinUnit, - locale() - ) - ) + amountInput.setSecondaryAmount(amount) } override fun hideSecondaryAmount() { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendActivity.kt index 1d6e4e83..28bd749e 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendActivity.kt @@ -13,6 +13,7 @@ import io.muun.apollo.R import io.muun.apollo.domain.model.OperationUri import io.muun.apollo.domain.model.P2PState import io.muun.apollo.presentation.ui.base.SingleFragmentActivity +import io.muun.apollo.presentation.ui.utils.OS import io.muun.apollo.presentation.ui.view.MuunButton import io.muun.apollo.presentation.ui.view.MuunButtonLayout import io.muun.apollo.presentation.ui.view.MuunContactList @@ -21,7 +22,7 @@ import io.muun.apollo.presentation.ui.view.MuunUriInput import io.muun.apollo.presentation.ui.view.MuunUriPaster import io.muun.apollo.presentation.ui.view.StatusMessage -class SendActivity: SingleFragmentActivity(), SendView { +class SendActivity : SingleFragmentActivity(), SendView { companion object { fun getStartActivityIntent(context: Context) = @@ -31,14 +32,17 @@ class SendActivity: SingleFragmentActivity(), SendView { @BindView(R.id.header) lateinit var muunHeader: MuunHeader + @BindView(R.id.paste_button) + lateinit var pasteButton: MuunButton + @BindView(R.id.uri_paster) lateinit var uriPaster: MuunUriPaster @BindView(R.id.uri_input) lateinit var uriInput: MuunUriInput - @BindView(R.id.uri_error_message) - lateinit var uriError: StatusMessage + @BindView(R.id.uri_status_message) + lateinit var uriStatusMessage: StatusMessage @BindView(R.id.contact_list) lateinit var contactList: MuunContactList @@ -49,7 +53,6 @@ class SendActivity: SingleFragmentActivity(), SendView { @BindView(R.id.button_layout) lateinit var buttonLayout: MuunButtonLayout - override fun inject() = component.inject(this) @@ -69,12 +72,16 @@ class SendActivity: SingleFragmentActivity(), SendView { uriPaster.onSelectListener = presenter::selectUriFromPaster + pasteButton.setOnClickListener { + presenter.pasteFromClipboard() + } + contactList.onSelectListener = presenter::selectContact contactList.onGoToP2PSetupListener = presenter::goToP2PSetup contactList.onGoToSettingsListener = presenter::goToSystemSettings uriInput.onScanQrClickListener = presenter::goToQrScanner - uriInput.onChangeListener = this::onUriInputChange + uriInput.onChangeListener = presenter::onUriInputChange confirmButton.setOnClickListener { confirmButton.setLoading(true) @@ -86,7 +93,7 @@ class SendActivity: SingleFragmentActivity(), SendView { override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - onUriInputChange(uriInput.content) + presenter.onUriInputChange(uriInput.content) } override fun setP2PState(state: P2PState) { @@ -114,19 +121,45 @@ class SendActivity: SingleFragmentActivity(), SendView { } } + override fun setClipboardStatus(containsPlainText: Boolean) { + if (OS.supportsClipboardAccessNotification()) { + pasteButton.visibility = View.VISIBLE + pasteButton.isEnabled = containsPlainText + } + } + override fun setClipboardUri(uri: OperationUri?) { uriPaster.uri = uri } - private fun onUriInputChange(content: String) { - buttonLayout.setButtonsVisible(content.isNotBlank()) + override fun pasteFromClipboard(clipboardContent: String) { + uriInput.content = clipboardContent + } + + override fun updateUriState(uriState: UriState) { + uriStatusMessage.visibility = View.GONE + + buttonLayout.setButtonsVisible(!uriState.isBlank) - confirmButton.isEnabled = presenter.isValidUri(content) + if (uriState.isPartiallyValid) { + + confirmButton.isEnabled = uriState.isValid + + if (uriState.isLastCopiedFromReceive) { + uriStatusMessage.visibility = View.VISIBLE + uriStatusMessage.setWarning( + getString(R.string.send_cyclic_payment_warning), + "", + true, + '.' + ) + } - if (presenter.isValidPartialUri(content)) { - uriError.visibility = View.GONE } else { - uriError.setError(R.string.send_uri_error_title, R.string.send_uri_error_body) + confirmButton.isEnabled = false + + uriStatusMessage.visibility = View.VISIBLE + uriStatusMessage.setError(R.string.send_uri_error_title, R.string.send_uri_error_body) } } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendPresenter.kt index 86d3b19a..a73d7fb2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendPresenter.kt @@ -9,13 +9,14 @@ import io.muun.apollo.domain.selector.ClipboardUriSelector import io.muun.apollo.domain.selector.P2PStateSelector import io.muun.apollo.presentation.ui.base.BasePresenter import io.muun.apollo.presentation.ui.base.di.PerActivity +import io.muun.apollo.presentation.ui.utils.OS import javax.inject.Inject @PerActivity class SendPresenter @Inject constructor( private val p2PStateSel: P2PStateSelector, - private val clipboardUriSel: ClipboardUriSelector -): BasePresenter() { + private val clipboardUriSel: ClipboardUriSelector, +) : BasePresenter() { companion object { const val MIN_ADDRESS_LENGTH_FOR_VALIDATION = 23 @@ -38,44 +39,47 @@ class SendPresenter @Inject constructor( } private fun setUpClipboard() { - val observable = clipboardUriSel - .watch() - .compose(getAsyncExecutor()) - .doOnNext(view::setClipboardUri) + if (!OS.supportsClipboardAccessNotification()) { + clipboardUriSel + .watch() + .compose(getAsyncExecutor()) + .doOnNext(view::setClipboardUri) + .let(this::subscribeTo) - subscribeTo(observable) + } else { + clipboardManager + .watchForPlainText() + .compose(getAsyncExecutor()) + .doOnNext(view::setClipboardStatus) + .let(this::subscribeTo) + } } - fun isValidPartialUri(maybeUri: String) = - maybeUri.length < MIN_ADDRESS_LENGTH_FOR_VALIDATION || isValidUri(maybeUri) - - fun isValidUri(maybeUri: String) = - try { - OperationUri.fromString(maybeUri) - true - } catch (e: Throwable) { - false - } + fun pasteFromClipboard() { + view.pasteFromClipboard(clipboardUriSel.getText()) + } /** * Select which screen to navigate to, based on the content of an OperationUri. */ fun selectUriFromPaster(uri: OperationUri) { + confirmOperationUri(uri, NewOperationOrigin.SEND_CLIPBOARD_PASTE) + } + + fun selectUriFromInput(uri: OperationUri) { + confirmOperationUri(uri, NewOperationOrigin.SEND_MANUAL_INPUT) + } + private fun confirmOperationUri(uri: OperationUri, origin: NewOperationOrigin) { if (uri.lnUrl.isPresent) { navigator.navigateToLnUrlWithdrawConfirm(context, uri.lnUrl.get()) } else { - navigator.navigateToNewOperation(context, NewOperationOrigin.SEND_CLIPBOARD_PASTE, uri) + navigator.navigateToNewOperation(context, origin, uri) } view.finishActivity() } - fun selectUriFromInput(uri: OperationUri) { - navigator.navigateToNewOperation(context, NewOperationOrigin.SEND_MANUAL_INPUT, uri) - view.finishActivity() - } - fun selectContact(contact: Contact) { navigator.navigateToNewOperation( context, @@ -99,6 +103,28 @@ class SendPresenter @Inject constructor( navigator.navigateToSystemSettings(context) } + fun onUriInputChange(inputContent: String) { + view.updateUriState( + UriState( + inputContent.isBlank(), + isValidUri(inputContent), + isValidPartialUri(inputContent), + clipboardUriSel.isLastCopiedFromReceive(inputContent) + ) + ) + } + + private fun isValidPartialUri(maybeUri: String) = + maybeUri.length < MIN_ADDRESS_LENGTH_FOR_VALIDATION || isValidUri(maybeUri) + + private fun isValidUri(maybeUri: String) = + try { + OperationUri.fromString(maybeUri) + true + } catch (e: Throwable) { + false + } + override fun getEntryEvent() = AnalyticsEvent.S_SEND() } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendView.kt index 5efefe1a..87ce198c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/SendView.kt @@ -4,9 +4,19 @@ import io.muun.apollo.domain.model.OperationUri import io.muun.apollo.domain.model.P2PState import io.muun.apollo.presentation.ui.base.BaseView -interface SendView: BaseView { +interface SendView : BaseView { fun setP2PState(state: P2PState) + /** + * Set whether clipboard contains plaintext content or not. + */ + fun setClipboardStatus(containsPlainText: Boolean) + fun setClipboardUri(uri: OperationUri?) + + fun pasteFromClipboard(clipboardContent: String) + + fun updateUriState(uriState: UriState) + } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/UriState.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/UriState.kt new file mode 100644 index 00000000..607e41ec --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/send/UriState.kt @@ -0,0 +1,8 @@ +package io.muun.apollo.presentation.ui.send + +class UriState( + val isBlank: Boolean, + val isValid: Boolean, + val isPartiallyValid: Boolean, + val isLastCopiedFromReceive: Boolean +) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrFragment.kt index cbe82474..4c355841 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrFragment.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Color import android.graphics.Point +import android.graphics.drawable.BitmapDrawable import android.view.View import android.view.WindowManager import android.widget.ImageView @@ -20,6 +21,7 @@ import io.muun.apollo.presentation.ui.base.SingleFragment import io.muun.apollo.presentation.ui.utils.isInNightMode import io.muun.apollo.presentation.ui.view.EditAmountItem import io.muun.apollo.presentation.ui.view.MuunButton +import timber.log.Timber abstract class QrFragment> : SingleFragment(), QrView { @@ -110,4 +112,10 @@ abstract class QrFragment> : SingleFragment safelyPullNotifications()) - .compose(transformerFactory.getAsyncExecutor()) - .subscribe(); - } - - private Observable safelyPullNotifications() { - return notificationActions.pullNotifications() - .onErrorReturn(error -> { - Timber.e(error); - return null; - }); - } - -} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/UiNotificationPoller.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/UiNotificationPoller.kt new file mode 100644 index 00000000..e0f7352f --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/UiNotificationPoller.kt @@ -0,0 +1,66 @@ +package io.muun.apollo.presentation.ui.utils + +import androidx.annotation.OpenForTesting +import io.muun.apollo.data.external.Globals +import io.muun.apollo.data.os.execution.ExecutionTransformerFactory +import io.muun.apollo.domain.action.NotificationActions +import io.muun.apollo.domain.errors.ExpiredSessionError +import rx.Observable +import rx.Subscription +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +// TODO open to make tests work with mockito. We should probably move to mockK +@OpenForTesting +open class UiNotificationPoller @Inject constructor( + private val notificationActions: NotificationActions, + private val transformerFactory: ExecutionTransformerFactory, +) { + + companion object { + private val CI = Globals.INSTANCE.oldBuildType == "regtestDebug" + private val POLL_INTERVAL_IN_SECS = if (CI) 5 else 2 + } + + private var subscription: Subscription? = null + + /** + * Start polling notifications every `POLL_INTERVAL_IN_SECS` seconds, in background. Note that + * the first call will happen immediately, rather than wait for the first interval. + */ + fun start() { + if (subscription == null || subscription!!.isUnsubscribed) { + subscription = subscribeToNotificationPolling() + } + } + + /** + * Stop polling. + */ + fun stop() { + if (subscription != null && !subscription!!.isUnsubscribed) { + subscription!!.unsubscribe() + subscription = null + } + } + + private fun subscribeToNotificationPolling(): Subscription { + return Observable + .interval(POLL_INTERVAL_IN_SECS.toLong(), TimeUnit.SECONDS) + .startWith(0L) + .onBackpressureLatest() + .concatMap { notificationActions.pullNotifications() } + .compose(transformerFactory.getAsyncExecutor()) + .subscribe({}, { error -> handleError(error) }) + } + + private fun handleError(error: Throwable) { + Timber.e(error) + // If ExpiredSessionError we stop polling to avoid Houston hits that we know that will return + // error (and probably fire monitoring alerts), otherwise we resubscribe to continue polling + if (error !is ExpiredSessionError) { + subscription = subscribeToNotificationPolling() + } + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/FeeManualInput.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/FeeManualInput.java index bd061831..de893d76 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/FeeManualInput.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/FeeManualInput.java @@ -100,7 +100,9 @@ protected int getLayoutResource() { @Override protected void setUp(@NonNull Context context, @Nullable AttributeSet attrs) { super.setUp(context, attrs); - getComponent().inject(this); + if (getComponent() != null) { + getComponent().inject(this); + } feeRateInput.addTextChangedListener(new TextWatcher() { @Override diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunAmountInput.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunAmountInput.java index 8258670b..46651962 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunAmountInput.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunAmountInput.java @@ -88,6 +88,10 @@ public interface OnChangeListener { @State float maxWidthPx; + + /** + * Part of our (ugly) hack to allow SATs as an input currency option. + */ @State BitcoinUnit bitcoinUnit; @@ -126,12 +130,17 @@ protected int getLayoutResource() { @Override protected void setUp(@NonNull Context context, @Nullable AttributeSet attrs) { super.setUp(context, attrs); - getComponent().inject(this); + if (getComponent() != null) { + getComponent().inject(this); + } this.textMaxWidthPercent = 1f; viewProps.transfer(attrs, this); - setUpNumberInput(); + // Avoid complex text decoration and listeners initialization for layout preview + if (!isInEditMode()) { + setUpNumberInput(); + } } private void setUpNumberInput() { @@ -189,8 +198,22 @@ public void setValue(MonetaryAmount amount) { /** * Set secondary amount's value and make it visible. */ - public void setSecondaryAmount(CharSequence amount) { - this.secondaryAmount.setText(amount); + public void setSecondaryAmount(MonetaryAmount amount) { + setSecondaryAmount("%s", amount); + } + + /** + * Set secondary amount's value and make it visible. + */ + public void setSecondaryAmount(String format, MonetaryAmount amount) { + final String formattedAmount = MoneyHelper.formatLongMonetaryAmount( + amount, + true, + bitcoinUnit, + ExtensionsKt.locale(getContext()) + ); + + this.secondaryAmount.setText(String.format(format, formattedAmount)); this.secondaryAmount.setVisibility(View.VISIBLE); } @@ -260,6 +283,12 @@ public void onExternalResult(int requestCode, int resultCode, Intent data) { @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + // Avoid tricky calls: getParent().getMeasuredWidth(), autoSizeDecoration.setMaxWidthPx() + if (isInEditMode()) { + return; + } + this.maxWidthPx = ((View) this.getParent()).getMeasuredWidth() * textMaxWidthPercent; if (autoSizeDecoration != null) { autoSizeDecoration.setMaxWidthPx(maxWidthPx); @@ -273,7 +302,7 @@ private void onNumberInputChange(String numberString) { MonetaryAmount newValue = Money.of(parseNumber(numberString), value.getCurrency()); - if (MoneyExtensionsKt.isBtc(newValue) && getBitcoinUnit() == BitcoinUnit.SATS) { + if (MoneyExtensionsKt.isBtc(newValue) && bitcoinUnit == BitcoinUnit.SATS) { newValue = newValue.divide(BitcoinUtils.SATOSHIS_PER_BITCOIN); } @@ -328,8 +357,10 @@ private void notifyChange(MonetaryAmount oldValue, MonetaryAmount newValue) { private void adjustFractionalDigits() { if (isSatSelectedAsCurrency()) { moneyDecoration.setMaxFractionalDigits(MoneyHelper.MAX_FRACTIONAL_DIGITS_SAT); + } else if (value.getCurrency().getCurrencyCode().equals("BTC")) { moneyDecoration.setMaxFractionalDigits(MoneyHelper.MAX_FRACTIONAL_DIGITS_BTC); + } else { moneyDecoration.setMaxFractionalDigits(MoneyHelper.MAX_FRACTIONAL_DIGITS_FIAT); } @@ -340,7 +371,7 @@ private void setTextMaxWidthPercent(float textMaxWidthPercent) { } private void updateCurrencyCodeText() { - updateCurrencyCodeText(getBitcoinUnit()); + updateCurrencyCodeText(bitcoinUnit); } private void updateCurrencyCodeText(BitcoinUnit bitcoinUnit) { @@ -359,7 +390,7 @@ private void updateAmountText(boolean isDueToCurrencyChange) { if (value.isPositive()) { final String text = MoneyHelper.formatInputMonetaryAmount( value, - getBitcoinUnit(), + bitcoinUnit, ExtensionsKt.locale(getContext()) ); @@ -388,13 +419,6 @@ private BigDecimal parseNumber(String numberString) { } } - /** - * Part of our (ugly) hack to allow SATs as an input currency option. - */ - public BitcoinUnit getBitcoinUnit() { - return bitcoinUnit; - } - /** * Part of our (ugly) hack to allow SATs as an input currency option. */ diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunLockOverlay.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunLockOverlay.java index 894f2cf8..88623915 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunLockOverlay.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunLockOverlay.java @@ -2,53 +2,41 @@ import io.muun.apollo.R; -import io.muun.apollo.data.logging.Crashlytics; -import io.muun.apollo.presentation.ui.utils.OS; import io.muun.common.utils.Preconditions; import android.content.Context; -import android.hardware.fingerprint.FingerprintManager; -import android.os.Build; -import android.os.CancellationSignal; +import android.os.Handler; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; import androidx.core.view.ViewCompat; import androidx.fragment.app.FragmentActivity; import butterknife.BindView; -import icepick.State; import rx.functions.Action0; +import timber.log.Timber; import javax.annotation.Nullable; public class MuunLockOverlay extends MuunView { + private static final int CONCURRENT_ACCESS_WINDOW_MS = 500; + public interface LockOverlayListener { /** - * This method will be called once a pin is successfully entered. + * This method will be called once a pin is fully/completely entered. */ void onPinEntered(String pin); - - /** - * This method will be called once a fingerprint is successfully entered. - */ - void onFingerprintEntered(); } @BindView(R.id.unlock_pin_input) MuunPinInput pinInput; - @State - boolean isFingerprintAllowed; - - private boolean isFingerprintEnabled; // could be allowed but not available or paused - private CancellationSignal cancelFingerprint; - private LockOverlayListener listener; + private String lastPin; + public MuunLockOverlay(Context context) { super(context); } @@ -77,6 +65,7 @@ protected void setUp(@NonNull Context context, @Nullable AttributeSet attrs) { * Attach this Overlay to the root view of its Context, covering the parent Activity. */ public void attachToRoot() { + Timber.d("Lifecycle: " + getClass().getSimpleName() + "#attachToRoot"); final ViewGroup contentView = getActivityContentView(); contentView.getChildAt(0).setVisibility(View.INVISIBLE); @@ -102,17 +91,6 @@ public void detachFromRoot() { } } - /** - * Allow the Overlay to read fingerprints, if able. - */ - public void setFingerprintAllowed(boolean isAllowed) { - this.isFingerprintAllowed = isAllowed; - - if (isFingerprintAllowed && !isFingerprintEnabled && getVisibility() == View.VISIBLE) { - tryEnableFingerprint(); - } - } - /** * Set the remaining and total attempts. */ @@ -128,33 +106,23 @@ public void setRemainingAttempts(int remainingAttempts, int maxAttempts) { */ public void reportError(Action0 onComplete) { pinInput.flashError(onComplete); + new Handler().postDelayed(this::resetLastPin, CONCURRENT_ACCESS_WINDOW_MS); } /** * Report to the Overlay that an unlock attempt succeeded. */ public void reportSuccess() { - Crashlytics.logBreadcrumb("MuunLockOverlay: reportSuccess"); + Timber.i("MuunLockOverlay: reportSuccess"); pinInput.setSuccess(); + new Handler().postDelayed(this::resetLastPin, CONCURRENT_ACCESS_WINDOW_MS); } public void setListener(LockOverlayListener lockOverlayListener) { this.listener = lockOverlayListener; } - @Override - protected void onVisibilityChanged(@NonNull View changedView, int visibility) { - super.onVisibilityChanged(changedView, visibility); - - if (visibility != View.VISIBLE) { - disableFingerprint(); - - } else if (isFingerprintAllowed && !isFingerprintEnabled) { - tryEnableFingerprint(); - } - } - private ViewGroup getActivityContentView() { final FragmentActivity activity = (FragmentActivity) getContext(); @@ -163,70 +131,22 @@ private ViewGroup getActivityContentView() { private void onPinEntered(String pin) { if (this.listener != null) { - this.listener.onPinEntered(pin); - } - } - - private void tryEnableFingerprint() { - if (OS.supportsFingerprintAPI()) { - enableFingerprintOnApi23(); - } - } - - private void disableFingerprint() { - if (cancelFingerprint != null) { - cancelFingerprint.cancel(); - cancelFingerprint = null; - } - - isFingerprintEnabled = false; - } - @RequiresApi(api = Build.VERSION_CODES.M) - private void enableFingerprintOnApi23() { - final FingerprintManager fingerprintManager = - (FingerprintManager) getContext().getSystemService(Context.FINGERPRINT_SERVICE); + // We've seen in some devices "duplicated" events being fired almost instantaneously, + // and we've observed that this causes trouble for keeping incorrectAttempts record in + // secure storage. We'll try to avoid these duplicated events firing in a short + // range from messing with secure storage's Keystore. + if (pin.equals(lastPin)) { + return; // If this is a "double fire"/"concurrent access" we'll dismiss it + } - final boolean canEnableFingerprint = (fingerprintManager != null) - && fingerprintManager.isHardwareDetected() - && fingerprintManager.hasEnrolledFingerprints(); + lastPin = pin; - if (! canEnableFingerprint) { - return; + this.listener.onPinEntered(pin); } - - final FingerprintCallback listener = new FingerprintCallback(); - cancelFingerprint = new CancellationSignal(); - - fingerprintManager.authenticate( - null, // No CryptoObject, we won't sign or encrypt with fingerprints - cancelFingerprint, // A CancellationSignal to stop listening - 0, // No flags (fun fact: none actually existed when I wrote this) - listener, // Our custom bag of friendly callbacks - null // No special Handler, use the default - ); - - isFingerprintEnabled = true; } - @RequiresApi(api = Build.VERSION_CODES.M) - private class FingerprintCallback extends FingerprintManager.AuthenticationCallback { - - @Override - public void onAuthenticationError(int errorCode, CharSequence errString) { - // TODO examine error code, decide what to do - } - - @Override - public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult result) { - if (listener != null) { - listener.onFingerprintEntered(); - } - } - - @Override - public void onAuthenticationFailed() { - // TODO visual feedback of failed fingerprint auth - } + private void resetLastPin() { + lastPin = null; } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunPictureInput.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunPictureInput.java index 5c1dff3b..f5e82a02 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunPictureInput.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunPictureInput.java @@ -99,7 +99,9 @@ protected int getLayoutResource() { @Override protected void setUp(@NonNull Context context, @Nullable AttributeSet attrs) { super.setUp(context, attrs); - getComponent().inject(this); + if (getComponent() != null) { + getComponent().inject(this); + } profilePictureView.setOnClickListener(view -> openIntentChooser()); profilePictureView.setListener((Uri uri) -> toggleLoading(false)); diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunRecoveryCodeBox.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunRecoveryCodeBox.java index 0e7ba2b0..99aef348 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunRecoveryCodeBox.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunRecoveryCodeBox.java @@ -77,6 +77,10 @@ protected int getLayoutResource() { protected void setUp(@NonNull Context context, @Nullable AttributeSet attrs) { super.setUp(context, attrs); + if (isInEditMode()) { + return; // For layout preview, we just want the view to be measured and rendered + } + Preconditions.checkState(segmentInputs.size() == RecoveryCodeV2.SEGMENT_COUNT); for (int index = 0; index < segmentInputs.size(); index++) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunTextInput.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunTextInput.java index 0a52613b..908523db 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunTextInput.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunTextInput.java @@ -179,7 +179,9 @@ protected void setUp(@NonNull Context context, @Nullable AttributeSet attrs) { isEnabled = true; super.setUp(context, attrs); - getComponent().inject(this); + if (getComponent() != null) { + getComponent().inject(this); + } editText.setImeOptions(FIXED_IME_OPTIONS); diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunUriInput.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunUriInput.kt index b6093bc3..04b4979e 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunUriInput.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunUriInput.kt @@ -47,5 +47,4 @@ class MuunUriInput @JvmOverloads constructor(c: Context, a: AttributeSet? = null onScanQrClickListener() } } - } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt index d77d5b22..f6e6ce1d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt @@ -83,7 +83,15 @@ abstract class MuunView : FrameLayout, // This is here for easy access of child views that need it. Let's try to avoid injection in // this base class so we don't perform unnecessary DI on all our views. - protected val component: ViewComponent by lazy { activity.applicationComponent.viewComponent() } + // NOTE: we return null in Edit Mode since views in layout preview run in an artificial context + // where we can't access Activity nor ApplicationComponent (required to perform Dagger DI). + protected val component: ViewComponent? by lazy { + if (!isInEditMode) { + activity.applicationComponent.viewComponent() + } else { + null + } + } // For convenience in Java subclasses protected val locale: Locale = if (isInEditMode) Locale.US else this.locale() diff --git a/android/apolloui/src/main/res/layout/activity_send.xml b/android/apolloui/src/main/res/layout/activity_send.xml index be39ddeb..55db9f4d 100644 --- a/android/apolloui/src/main/res/layout/activity_send.xml +++ b/android/apolloui/src/main/res/layout/activity_send.xml @@ -35,7 +35,7 @@ android:layout_marginBottom="16dp" /> + + @@ -35,7 +36,7 @@ android:layout_width="match_parent" android:layout_height="56dp"> - + android:layout_height="match_parent" + android:fillViewport="true"> - + + diff --git a/android/apolloui/src/main/res/layout/view_paste_from_clipboard.xml b/android/apolloui/src/main/res/layout/view_paste_from_clipboard.xml new file mode 100644 index 00000000..b30871f7 --- /dev/null +++ b/android/apolloui/src/main/res/layout/view_paste_from_clipboard.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/android/apolloui/src/main/res/values-es/strings.xml b/android/apolloui/src/main/res/values-es/strings.xml index a41fbed8..9334bbb2 100644 --- a/android/apolloui/src/main/res/values-es/strings.xml +++ b/android/apolloui/src/main/res/values-es/strings.xml @@ -498,6 +498,10 @@ Ésta no es una dirección de BTC o factura de lightning. + Enviarás los fondos a tu monedero Muun + + Pegar del portapeles + El portapapeles no contiene una LNURL @string/camera_permission_request_title @@ -1168,7 +1172,7 @@ Unidad de bitcoin Bitcoin (BTC) - Bitcoin (SAT) + Satoshi (SAT) Centro de Seguridad diff --git a/android/apolloui/src/main/res/values/strings.xml b/android/apolloui/src/main/res/values/strings.xml index e49e0902..1edffe53 100644 --- a/android/apolloui/src/main/res/values/strings.xml +++ b/android/apolloui/src/main/res/values/strings.xml @@ -477,6 +477,10 @@ Continue Invalid address or invoice This is not a bitcoin address or lightning invoice. + You\'ll send funds to your Muun wallet + + Paste from clipboard + Clipboard doesn\'t contain an LNURL @string/camera_permission_request_title @@ -1128,7 +1132,7 @@ Bitcoin unit Bitcoin (BTC) - Bitcoin (SAT) + Satoshi (SAT) Security Center diff --git a/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/VerifyEmailPresenterTest.kt b/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/VerifyEmailPresenterTest.kt deleted file mode 100644 index f7ed404f..00000000 --- a/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/VerifyEmailPresenterTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package io.muun.apollo.presentation.presenters - -import android.os.Bundle -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions -import io.muun.apollo.data.external.Gen -import io.muun.apollo.data.preferences.UserRepository -import io.muun.apollo.domain.action.base.ActionState -import io.muun.apollo.domain.action.session.UseMuunLinkAction -import io.muun.apollo.domain.action.user.EmailLinkAction -import io.muun.apollo.presentation.BasePresentationTest -import io.muun.apollo.presentation.ui.fragments.verify_email.VerifyEmailParentPresenter -import io.muun.apollo.presentation.ui.fragments.verify_email.VerifyEmailPresenter -import io.muun.apollo.presentation.ui.utils.UiNotificationPoller -import org.junit.Test -import rx.subjects.BehaviorSubject - -class VerifyEmailPresenterTest: BasePresentationTest() { - - @Test - fun shouldSetEmailInView() { - val user = Gen.user(email = "user1@muun.com") - - val parent = mock { - on { getEmail() } doReturn(user.email.get()) - } - - val notificationPoller = mock() - - val useMuunLinkResult = BehaviorSubject.create>() - - val useMuunLinkAction = mock { - on { state } doReturn(useMuunLinkResult) - } - - val userRepository = mock() - - val emailLinkAction = object: EmailLinkAction(userRepository) {} - - val presenter = object: VerifyEmailPresenter( - notificationPoller, - useMuunLinkAction, - emailLinkAction) { - - override fun setUpDeprecatedClientVersionCheck() {} - override fun setUpSessionExpiredCheck() {} - override fun setUpNetworkInfo() {} - } - - presenter.setParentPresenter(parent) - presenter.setView(mock()) - presenter.setUp(Bundle()) - - verify(presenter.view, times(1)).setEmail(user.email.get()) - verifyNoMoreInteractions(presenter.view) - } -} \ No newline at end of file diff --git a/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmountTest.kt b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmountTest.kt new file mode 100644 index 00000000..c97b765b --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/new_operation/DisplayAmountTest.kt @@ -0,0 +1,81 @@ +package io.muun.apollo.presentation.ui.new_operation + +import io.muun.apollo.domain.model.BitcoinAmount +import io.muun.apollo.domain.model.BitcoinUnit +import io.muun.common.utils.BitcoinUtils +import org.assertj.core.api.Assertions.assertThat +import org.javamoney.moneta.Money +import org.junit.Test +import javax.money.CurrencyUnit +import javax.money.Monetary + +internal class DisplayAmountTest { + + private val usd = Monetary.getCurrency("USD") + private val ars = Monetary.getCurrency("ARS") + private val btc = Monetary.getCurrency("BTC") + + class InputData(val inputCurrency: CurrencyUnit, val primaryCurrency: CurrencyUnit) + class ExpectedOutput(val rotatedCurrency: Array) + + @Test + fun testRotateCurrency() { + + testRotate(InputData(usd, ars), ExpectedOutput(arrayOf(ars, btc, usd))) + testRotate(InputData(usd, usd), ExpectedOutput(arrayOf(btc, usd, btc))) + testRotate(InputData(usd, btc), ExpectedOutput(arrayOf(btc, usd, btc))) + testRotate(InputData(btc, usd), ExpectedOutput(arrayOf(usd, btc, usd))) + } + + private fun testRotate(inputData: InputData, expectedOutput: ExpectedOutput) { + + val bitcoinAmount = BitcoinAmount( + BitcoinUtils.bitcoinsToSatoshis(Money.of(1, btc)), + Money.of(1, inputData.inputCurrency), + Money.of(1, inputData.primaryCurrency) + ) + + val displayAmount = DisplayAmount(bitcoinAmount, BitcoinUnit.BTC, false) + + var rotatedAmount = displayAmount.rotateCurrency(inputData.inputCurrency.currencyCode) + assertThat(rotatedAmount.currency).isEqualTo(expectedOutput.rotatedCurrency[0]) + + rotatedAmount = displayAmount.rotateCurrency(rotatedAmount.currency.currencyCode) + assertThat(rotatedAmount.currency).isEqualTo(expectedOutput.rotatedCurrency[1]) + + rotatedAmount = displayAmount.rotateCurrency(rotatedAmount.currency.currencyCode) + assertThat(rotatedAmount.currency).isEqualTo(expectedOutput.rotatedCurrency[2]) + + // This shouldn't really happen but in case of error we fallback to BTC + rotatedAmount = displayAmount.rotateCurrency("COP") + assertThat(rotatedAmount.currency).isEqualTo(btc) + } + + @Test + fun testDisplayOfBitcoinAmounts() { + val nonBtc = usd + testDisplay(btc, BitcoinUnit.BTC, false, BitcoinUnit.BTC) + testDisplay(nonBtc, BitcoinUnit.BTC, false, BitcoinUnit.BTC) + testDisplay(btc, BitcoinUnit.BTC, true, BitcoinUnit.SATS) + testDisplay(nonBtc, BitcoinUnit.BTC, true, BitcoinUnit.SATS) + testDisplay(btc, BitcoinUnit.SATS, false, BitcoinUnit.BTC) + testDisplay(nonBtc, BitcoinUnit.SATS, false, BitcoinUnit.SATS) + testDisplay(btc, BitcoinUnit.SATS, true, BitcoinUnit.SATS) + testDisplay(nonBtc, BitcoinUnit.SATS, true, BitcoinUnit.SATS) + } + + private fun testDisplay( + inputCurrency: CurrencyUnit, + btcUnit: BitcoinUnit, + satAsCurrency: Boolean, + expected: BitcoinUnit, + ) { + val bitcoinAmount = BitcoinAmount( + BitcoinUtils.bitcoinsToSatoshis(Money.of(1, btc)), + Money.of(1, inputCurrency), + Money.of(1, ars) + ) + val displayAmount = DisplayAmount(bitcoinAmount, btcUnit, satAsCurrency) + assertThat(displayAmount.getBitcoinUnit()).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/common/src/main/java/io/muun/common/utils/CollectionUtils.java b/common/src/main/java/io/muun/common/utils/CollectionUtils.java index 9334d477..a013b875 100644 --- a/common/src/main/java/io/muun/common/utils/CollectionUtils.java +++ b/common/src/main/java/io/muun/common/utils/CollectionUtils.java @@ -5,7 +5,10 @@ import rx.functions.Func1; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; @@ -90,4 +93,26 @@ public static List> zip(List a, List b) { return result; } + + /** + * Create a map by zipping together two lists of elements. + */ + public static Map zipToMap(List keys, List values) { + Preconditions.checkArgument(keys.size() == values.size()); + + final Map map = new HashMap(); + final Iterator keyIterator = keys.iterator(); + final Iterator valueIterator = values.iterator(); + + while (keyIterator.hasNext() && valueIterator.hasNext()) { + final K key = keyIterator.next(); + final V value = map.put(key, valueIterator.next()); + if (value != null) { + throw new IllegalArgumentException( + "Keys are not unique! Key " + key + " found more then once"); + } + } + + return map; + } } diff --git a/common/src/main/java/io/muun/common/utils/Preconditions.java b/common/src/main/java/io/muun/common/utils/Preconditions.java index 54987088..240248e2 100644 --- a/common/src/main/java/io/muun/common/utils/Preconditions.java +++ b/common/src/main/java/io/muun/common/utils/Preconditions.java @@ -317,8 +317,6 @@ public static long checkPositive(long number) { return number; } - - /** * Ensures that {@code number} is positive. * @@ -335,6 +333,22 @@ public static int checkPositive(int number, @Nullable Object errorMessage) { return number; } + /** + * Ensures that {@code number} is positive. + * + * @param number a number + * @param errorMessage the exception message to use if the check fails; will be converted to a + * string using {@link String#valueOf(Object)} + * @return the value of {@code number} + * @throws IllegalArgumentException if {@code number} is not positive + */ + public static long checkPositive(long number, @Nullable Object errorMessage) { + if (number <= 0) { + throw new IllegalArgumentException(String.valueOf(errorMessage)); + } + return number; + } + /** * Ensures that {@code number} is positive. * diff --git a/common/src/test/java/io/muun/common/utils/HexUtilsTest.java b/common/src/test/java/io/muun/common/utils/HexUtilsTest.java new file mode 100644 index 00000000..fafb2315 --- /dev/null +++ b/common/src/test/java/io/muun/common/utils/HexUtilsTest.java @@ -0,0 +1,54 @@ +package io.muun.common.utils; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HexUtilsTest { + + @Test + public void testEmptyEncode() throws Exception { + final byte[] emptyArray = {}; + assertThat(Encodings.bytesToHex(emptyArray)).isEmpty(); + } + + @Test + public void testValidEncode() throws Exception { + final byte[] bytes = {0x01, 0x02, (byte) 0xFF}; + assertThat(Encodings.bytesToHex(bytes)).isEqualToIgnoringCase("0102ff"); + } + + @Test(expected = IllegalArgumentException.class) + public void testDecodeOddLengthStringFails() throws Exception { + Encodings.hexToBytes("a"); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidCharDecode() throws Exception { + Encodings.hexToBytes("g"); + } + + @Test + public void testDecodeEmptyString() throws Exception { + assertThat(Encodings.hexToBytes("")).isEmpty(); + } + + @Test + public void testValidUpperCaseDecode() throws Exception { + final byte[] bytes = {0x01, 0x02, (byte) 0xFF}; + assertThat(Encodings.hexToBytes("0102FF")).isEqualTo(bytes); + } + + @Test + public void testValidLowerCaseDecode() throws Exception { + final byte[] bytes = {(byte) 0xca, (byte) 0xb2, (byte) 0xFF}; + assertThat(Encodings.hexToBytes("cab2ff")).isEqualTo(bytes); + } + + @Test + public void testValidMixedCaseDecode() throws Exception { + final byte[] bytes = {(byte) 0xaa, (byte) 0xbb, (byte) 0xdd}; + assertThat(Encodings.hexToBytes("AaBbdD")).isEqualTo(bytes); + } + +} \ No newline at end of file diff --git a/common/src/test/java/io/muun/common/utils/HexUtilsTest.kt b/common/src/test/java/io/muun/common/utils/HexUtilsTest.kt deleted file mode 100644 index 2c38e5b5..00000000 --- a/common/src/test/java/io/muun/common/utils/HexUtilsTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package io.muun.common.utils - -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test - -class HexUtilsTest { - - @Test - @Throws(Exception::class) - fun `test empty encode`() { - val emptyArray = byteArrayOf() - assertThat(Encodings.bytesToHex(emptyArray)).isEmpty() - } - - @Test - @Throws(Exception::class) - fun `test valid encode`() { - val bytes = byteArrayOf(0x01, 0x02, 0xFF.toByte()) - assertThat(Encodings.bytesToHex(bytes)).isEqualToIgnoringCase("0102ff") - } - - @Test(expected = IllegalArgumentException::class) - @Throws(Exception::class) - fun `test decode odd length string fails`() { - Encodings.hexToBytes("a") - } - - @Test(expected = IllegalArgumentException::class) - @Throws(Exception::class) - fun `test invalid char decode`() { - Encodings.hexToBytes("g") - } - - @Test - @Throws(Exception::class) - fun `test decode empty string`() { - assertThat(Encodings.hexToBytes("")).isEmpty() - } - - @Test - @Throws(Exception::class) - fun `test valid upper case decode`() { - val bytes = byteArrayOf(0x01, 0x02, 0xFF.toByte()) - assertThat(Encodings.hexToBytes("0102FF")).isEqualTo(bytes) - } - - @Test - @Throws(Exception::class) - fun `test valid lower case decode`() { - val bytes = byteArrayOf(0xca.toByte(), 0xb2.toByte(), 0xFF.toByte()) - assertThat(Encodings.hexToBytes("cab2ff")).isEqualTo(bytes) - } - - @Test - @Throws(Exception::class) - fun `test valid mixed case decode 1`() { - val bytes = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xdd.toByte()) - assertThat(Encodings.hexToBytes("AaBbdD")).isEqualTo(bytes) - } - -} \ No newline at end of file diff --git a/libwallet/fees/fees.go b/libwallet/fees/fees.go index 87dc324f..6b54fd4d 100644 --- a/libwallet/fees/fees.go +++ b/libwallet/fees/fees.go @@ -4,16 +4,20 @@ import "github.com/btcsuite/btcutil" const dustThreshold = 546 +// BestRouteFees represents a possible route for a lightning payment. In particular, it encodes the fee +// policy of such route (e.g how the route charges fees) and how a big a payment it can handle/route (e.g what is +// the maximum amount that is routable/payable via this route). type BestRouteFees struct { - MaxCapacity btcutil.Amount - FeeProportionalMillionth uint64 - FeeBase btcutil.Amount + MaxCapacity btcutil.Amount // maximum amount that is routable/payable via this route + FeeProportionalMillionth uint64 // fee proportion of the routed amount, divided by a million + FeeBase btcutil.Amount // fixed fee component. For a specific route: TotalFee=(FeeProportionalMillionth*amount)/1000000 + FeeBase } +// FundingOutputPolicies represents the conditions that decide how the funding output is created. type FundingOutputPolicies struct { - MaximumDebt btcutil.Amount - PotentialCollect btcutil.Amount - MaxAmountFor0Conf btcutil.Amount + MaximumDebt btcutil.Amount // maximum amount of debt that we're ok with lending this user, according swap provider risk tolerance + PotentialCollect btcutil.Amount // amount of debt we can effectively collect for a specific swap. + MaxAmountFor0Conf btcutil.Amount // maximum amount allowed for a 0-conf swap. Greater amounts will require 1-conf (higher fees, worse UX). Depends on swap provider risk tolerance. } type DebtType string diff --git a/libwallet/lnurl/lnurl.go b/libwallet/lnurl/lnurl.go index d5e0b19f..a5453afa 100644 --- a/libwallet/lnurl/lnurl.go +++ b/libwallet/lnurl/lnurl.go @@ -292,6 +292,8 @@ var reasons = map[string]int{ "withdraw link is empty": ErrAlreadyUsed, // This LNURL has already been used (thndr.io) "has already been used": ErrAlreadyUsed, + // Mainly to emulate ErrUnreachable error in CI, UI tests + "is unresponsive": ErrUnreachable, } func mapReasonToErrorCode(reason string) int { diff --git a/libwallet/operation/payment_analyzer.go b/libwallet/operation/payment_analyzer.go index c646798f..a7f92064 100644 --- a/libwallet/operation/payment_analyzer.go +++ b/libwallet/operation/payment_analyzer.go @@ -94,12 +94,13 @@ const ( AnalysisStatusUnpayable AnalysisStatus = "Unpayable" ) +// PaymentAnalysis encodes whether a payment can be made or not and some important extra metadata about the payment. type PaymentAnalysis struct { - Status AnalysisStatus - AmountInSat int64 - FeeInSat int64 - SwapFees *fees.SwapFees - TotalInSat int64 + Status AnalysisStatus // encodes the result of a payment's analysis + AmountInSat int64 // payment amount (e.g the amount the recipient will receive) + FeeInSat int64 // encodes the onchain fee (other fees may apply, e.g routing/lightning fee) + SwapFees *fees.SwapFees // metadata related to the swap (if one exists for payment) + TotalInSat int64 // AmountInSat + fees (may include other than FeeInSat). May provide extra information in case of error status (e.g payment can't be made). } func NewPaymentAnalyzer(feeWindow *FeeWindow, nts *NextTransactionSize) *PaymentAnalyzer { @@ -322,6 +323,26 @@ func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees }, nil } +// analyzeTFFAAmountlessInvoiceSwap takes care of the insurmountable task of deciding whether a take +// take fee from amount payment for an amountless invoice swap can be made, and (if it can) what are +// the fees and the destination/payment amount. +// This is particularly tricky since we have kind of a circular dependency: we don't know the payment amount which +// determines the fees (on-chain and lightning), and we need both to determine the number of confirmations required for +// the swap, which affects the on-chain fee, which affects the amount (since this is TFFA). +// For this implementation built from the assumptions that 0-conf on-chain fees are lower than 1-conf fees +//(since we don't have to wait for a block to make the payment). +// Here we go: +// 1. We calculate the on-chain fee for a 0-conf swap spending all funds +// - If that fee is greater than our balance -> payment can't be made (VERY low balance scenario) +// +// 2. We calculate the amount, routing fee and on-chain fee for a 0-conf TFFA swap +// 3. We determine the number of confirmations required for the calculated amount and routing fee. +// - If 0-conf -> we're good to continue +// - If 1-conf -> we perform step 2 for a 1-conf TFF swap (re calculate amount, routing fee and on-chain fee) +// +// 4. If amount <= 0 -> payment can't be made +// If amount > 0 -> AWESOME! That's the payment amount. +// 5. We determine the params of the funding output (SwapFees) and perform final checks to decide if payment can be made func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInvoice) (*PaymentAnalysis, error) { zeroConfFeeRate, err := a.feeWindow.SwapFeeRate(0) @@ -341,10 +362,13 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv params, err := a.computeParamsForTFFASwap(payment, 0) if err != nil { + // This LITERALLY can never happen, as only source of error for computeParamsForTFFASwap are: + // - negative conf target (we're using 0) + // - no route for amount (should be guaranteed by BestRouteFees struct) return &PaymentAnalysis{ Status: AnalysisStatusUnpayable, FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + int64(zeroConfFeeInSat), + TotalInSat: a.totalBalance() + zeroConfFeeInSat, }, nil } @@ -354,11 +378,11 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv if err != nil { // This LITERALLY can never happen, as only source of error for computeParamsForTFFASwap are: // - negative conf target (we're using 1) - // - no route for amount (would be catched by previous call since amount with 1-conf fee would be smaller) + // - no route for amount (should be guaranteed by BestRouteFees struct) return &PaymentAnalysis{ Status: AnalysisStatusUnpayable, FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + int64(zeroConfFeeInSat), + TotalInSat: a.totalBalance() + zeroConfFeeInSat, }, nil } } @@ -374,7 +398,7 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv return &PaymentAnalysis{ Status: AnalysisStatusUnpayable, FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + int64(zeroConfFeeInSat), + TotalInSat: a.totalBalance() + zeroConfFeeInSat, }, nil } @@ -431,10 +455,11 @@ type swapParams struct { RoutingFee btcutil.Amount } -// computeParamsForTFFASwap takes care of the VERY COMPLEX tasks of calculating -// the amount, routing fee and on-chain fee for a TFFA swap. Calculating the -// on-chain fee is pretty straightforward but for the other two we use what -// we call "the equation". Let's dive into how it works: +// computeParamsForTFFASwap takes care of the VERY COMPLEX task of calculating +// the amount, routing fee and on-chain fee for a TFFA swap, given a specified +// number of confirmations required for the swap. Calculating the on-chain fee +// is pretty straightforward but for the other two we use what we call +// "the equation". Let's dive into how it works: // // Let: // - x be the payment amount @@ -447,14 +472,13 @@ type swapParams struct { // // For amountLessInvoices, we need to figure out x and l such that x + l = y - h // -// Note: -// - we don't care about debt (except to calculate user balance) since it -// doesn't affect that payment/offchain amount and thus the routingFee. It may -// affect the onchain fee though, which is already calculated considering it. -// - we don't care about output padding, since it can either be: -// - issued debt, in which case point above still holds -// - taken by fee, which would mean we are in a TFFA for a sub-dust amount, -// which is unpayable since we don't have balance to add as padding/fee. +// Note that we don't care about debt (except to calculate user balance) since it +// doesn't affect that payment/offchain amount and thus the routingFee. It may +// affect the onchain fee though, which is already calculated considering it. +// We also don't care about output padding, since it can either be: +// - issued debt, in which case point above still holds +// - taken by fee, which would mean we are in a TFFA for a sub-dust amount, +// which is unpayable since we don't have balance to add as padding/fee. // // Suppose we have only one route. // Then, x + l(x) = y - h @@ -481,7 +505,6 @@ type swapParams struct { // x = (y - h - b) / (FeeProportionalMillionth/1_000_000 + 1) // x = (y - h - b) / (FeeProportionalMillionth + 1_000_000) / 1_000_000 // x = ((y - h - b) * 1_000_000) / (FeeProportionalMillionth + 1_000_000) -// func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, confs uint) (*swapParams, error) { feeRate, err := a.feeWindow.SwapFeeRate(confs) @@ -523,6 +546,8 @@ func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, co }, nil } } + + // This shouldn't happen. BestRouteFees should guarantee that there's a route for each amount. return nil, errors.New("none of the best route fees have enough capacity") }