diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 8131fbb3..df1e33c2 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -6,6 +6,16 @@ follow [https://changelog.md/](https://changelog.md/) guidelines. ## [Unreleased] +## [49.3] - 2022-04-26 + +### ADDED +- Better error reporting and extra metadata for MoneyDecoration (MuunAmountInput) crash + +### FIXED +- Added missing error metadata to some crashlytics errors (e.g for background task of anon users) +- Invoice expiration time label getting cut off due to very long expiration time formatting +- Avoid crashing and add debug snapshot with audit trail for SecureStorageErrors + ## [49.2] - 2022-03-22 ### FIXED diff --git a/android/apollo/src/main/java/io/muun/apollo/data/async/gcm/GcmMessageListenerService.java b/android/apollo/src/main/java/io/muun/apollo/data/async/gcm/GcmMessageListenerService.java index b720a992..0f1edb10 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/async/gcm/GcmMessageListenerService.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/async/gcm/GcmMessageListenerService.java @@ -6,6 +6,7 @@ import io.muun.apollo.data.net.ModelObjectsMapper; import io.muun.apollo.data.os.execution.ExecutionTransformerFactory; import io.muun.apollo.data.serialization.SerializationUtils; +import io.muun.apollo.domain.LoggingContextManager; import io.muun.apollo.domain.action.NotificationActions; import io.muun.apollo.domain.action.fcm.UpdateFcmTokenAction; import io.muun.apollo.domain.errors.FcmMessageProcessingError; @@ -20,6 +21,9 @@ public class GcmMessageListenerService extends FirebaseMessagingService { + @Inject + LoggingContextManager loggingContextManager; + @Inject DaoManager daoManager; // not used directly by us, but needed in case we start the Application @@ -46,6 +50,8 @@ public void onCreate() { provider.getDataComponent().inject(this); Timber.d("Starting GcmMessageListenerService"); + + loggingContextManager.setupCrashlytics(); } @Override diff --git a/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/MuunWorkerFactory.kt b/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/MuunWorkerFactory.kt index ded62c84..446c3dee 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/MuunWorkerFactory.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/MuunWorkerFactory.kt @@ -8,6 +8,7 @@ import androidx.work.WorkerParameters import io.muun.apollo.data.external.DataComponentProvider import io.muun.apollo.data.external.NotificationService import io.muun.apollo.data.os.execution.ExecutionTransformerFactory +import io.muun.apollo.domain.LoggingContextManager import io.muun.apollo.domain.action.UserActions import io.muun.apollo.domain.errors.MuunError import io.muun.common.utils.Preconditions @@ -28,6 +29,9 @@ class MuunWorkerFactory(provider: DataComponentProvider) : WorkerFactory() { @Inject lateinit var userActions: UserActions + @Inject + lateinit var loggingContextManager: LoggingContextManager + @Inject lateinit var transformerFactory: ExecutionTransformerFactory @@ -48,6 +52,8 @@ class MuunWorkerFactory(provider: DataComponentProvider) : WorkerFactory() { // Should be enforce by WorkManager API but still (why don't they use Class param?!) Preconditions.checkArgument(Worker::class.java.isAssignableFrom(workerClass)) + loggingContextManager.setupCrashlytics() + when (workerClass) { PeriodicTaskWorker::class.java -> { diff --git a/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/PeriodicTaskWorker.java b/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/PeriodicTaskWorker.java index c90062a8..f7dc211a 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/PeriodicTaskWorker.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/async/tasks/PeriodicTaskWorker.java @@ -4,6 +4,7 @@ import io.muun.apollo.domain.action.UserActions; import io.muun.apollo.domain.errors.NoStackTraceException; import io.muun.apollo.domain.errors.PeriodicTaskError; +import io.muun.common.utils.Preconditions; import android.content.Context; import android.os.SystemClock; @@ -51,7 +52,7 @@ public Result doWork() { // minutes of execution if your task has not returned it will be considered to have timed // out, and the wakelock will be released. - final String type = getInputData().getString(TASK_TYPE_KEY); + final String type = Preconditions.checkNotNull(getInputData().getString(TASK_TYPE_KEY)); Timber.d("Running periodic task of type %s", type); @@ -60,30 +61,30 @@ public Result doWork() { try { taskDispatcher.dispatch(type) - .doOnError(throwable -> { - - if (throwable.getStackTrace() == null) { - fillInStackTrace(throwable); - } - - if (throwable.getStackTrace() == null) { - - final String message = String.format( - "Exception of type %s with no stacktrace, while running a periodic " - + "task of type %s. Message: %s.", - throwable.getClass().getCanonicalName(), - type, - throwable.getMessage() - ); - - throwable = new NoStackTraceException(message); - } - - Timber.e(throwable); - }) - .compose(transformerFactory.getAsyncExecutor()) - .toBlocking() - .subscribe(); + .doOnError(throwable -> { + + if (throwable.getStackTrace() == null) { + fillInStackTrace(throwable); + } + + if (throwable.getStackTrace() == null) { + + final String message = String.format( + "Exception of type %s with no stacktrace, while running a " + + "periodic task of type %s. Message: %s.", + throwable.getClass().getCanonicalName(), + type, + throwable.getMessage() + ); + + throwable = new NoStackTraceException(message); + } + + Timber.e(throwable); + }) + .compose(transformerFactory.getAsyncExecutor()) + .toBlocking() + .subscribe(); } catch (RuntimeException error) { Timber.e(new PeriodicTaskError(type, secondsSince(startMs), error)); diff --git a/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java b/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java index 1b5ab7cb..71555aa0 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java @@ -28,6 +28,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.Set; import javax.crypto.KeyGenerator; import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; @@ -79,6 +81,8 @@ private void generateKeyStore(String keyAlias) { } /** + * Encrypt a value using an unique keystore-backed key. + * * @param input Data to encrypt. * @param alias Key alias under which a key will be generated in the keystore. * @param iv Initialization vector which will prevent an attacker to easily figure out the @@ -91,6 +95,9 @@ public byte[] encryptData(byte[] input, String alias, byte[] iv) { final String keyAlias = getAlias(alias); if (!hasKey(keyAlias)) { + // FIXME: This is a racy operation and might end up creating 2 keys overwriting + // each other. That might mean we store the encrypted value using key 1 but keystore + // has key 2 stored. generateKeyStore(keyAlias); } @@ -105,6 +112,8 @@ public byte[] encryptData(byte[] input, String alias, byte[] iv) { } /** + * Decrypt a value using it's unique key backed by the keystore. + * * @param input Data to decrypt. * @param alias Key alias under which the data was encrypted in the first place. * @param iv Initialization vector that was user to encrypt the data. @@ -141,10 +150,10 @@ private void generateKeyStoreM(String keyAlias) { keyGenerator.generateKey(); - } catch ( - NoSuchAlgorithmException - | NoSuchProviderException - | InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException + | NoSuchProviderException + | InvalidAlgorithmParameterException e) { + Timber.e(e); throw new MuunKeyStoreException(e); } @@ -205,10 +214,10 @@ private void generateKeyStoreJ(String keyAlias) { generator.initialize(spec); generator.generateKeyPair(); - } catch ( - NoSuchAlgorithmException - | NoSuchProviderException - | InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException + | NoSuchProviderException + | InvalidAlgorithmParameterException e) { + Timber.e(e); throw new MuunKeyStoreException(e); } @@ -217,18 +226,17 @@ private void generateKeyStoreJ(String keyAlias) { @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) private byte[] encryptDataJ(byte[] inputData, String keyAlias, KeyStore keyStore) { try { - final PrivateKeyEntry privateKeyEntry - = (PrivateKeyEntry) keyStore.getEntry(keyAlias, null); - - return CryptographyWrapper.rsaEncrypt(inputData, privateKeyEntry); - - } catch ( - KeyStoreException - | NoSuchAlgorithmException - | UnrecoverableEntryException - | InvalidKeyException - | NoSuchPaddingException - | IOException e) { + final PrivateKeyEntry entry = (PrivateKeyEntry) keyStore.getEntry(keyAlias, null); + + return CryptographyWrapper.rsaEncrypt(inputData, entry); + + } catch (KeyStoreException + | NoSuchAlgorithmException + | UnrecoverableEntryException + | InvalidKeyException + | NoSuchPaddingException + | IOException e) { + Timber.e(e); throw new MuunKeyStoreException(e); } @@ -236,26 +244,26 @@ private byte[] encryptDataJ(byte[] inputData, String keyAlias, KeyStore keyStore @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) private byte[] decryptDataJ(byte[] input, String keyAlias, KeyStore keyStore) { - PrivateKeyEntry privateKeyEntry = null; try { - privateKeyEntry = (PrivateKeyEntry) keyStore.getEntry(keyAlias, - null); - - return CryptographyWrapper.rsaDecrypt(input, privateKeyEntry); - - } catch ( - NoSuchAlgorithmException - | NoSuchPaddingException - | UnrecoverableEntryException - | KeyStoreException - | InvalidKeyException - | IOException e) { + final PrivateKeyEntry entry = (PrivateKeyEntry) keyStore.getEntry(keyAlias, null); + + return CryptographyWrapper.rsaDecrypt(input, entry); + + } catch (NoSuchAlgorithmException + | NoSuchPaddingException + | UnrecoverableEntryException + | KeyStoreException + | InvalidKeyException + | IOException e) { + Timber.e(e); throw new MuunKeyStoreException(e); } } /** + * Check whether an alias is present in the keystore. + * * @param keyAlias Key alias that was used to encrypt data. * @return True if that key was generated. */ @@ -312,6 +320,14 @@ public void wipe() { } } + Set getAllLabels() throws MuunKeyStoreException { + try { + return new HashSet<>(Collections.list(loadKeystore().aliases())); + } catch (KeyStoreException e) { + throw new MuunKeyStoreException(e); + } + } + public static class MuunKeyStoreException extends CryptographyException { public MuunKeyStoreException(Throwable cause) { diff --git a/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStoragePreferences.java b/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStoragePreferences.java index 4dba0dda..b764ad96 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStoragePreferences.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/os/secure_storage/SecureStoragePreferences.java @@ -1,12 +1,26 @@ package io.muun.apollo.data.os.secure_storage; +import io.muun.apollo.data.preferences.adapter.JsonListPreferenceAdapter; +import io.muun.apollo.data.preferences.rx.Preference; +import io.muun.apollo.data.preferences.rx.RxSharedPreferences; import io.muun.apollo.data.serialization.SerializationUtils; +import io.muun.common.crypto.CryptographyException; +import io.muun.common.utils.Dates; import io.muun.common.utils.RandomGenerator; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; - +import com.google.common.annotations.VisibleForTesting; +import org.threeten.bp.Instant; +import org.threeten.bp.ZoneOffset; +import org.threeten.bp.ZonedDateTime; +import timber.log.Timber; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import javax.inject.Inject; import javax.inject.Singleton; @@ -17,12 +31,32 @@ public class SecureStoragePreferences { private static final String AES_IV_KEY_PREFIX = "aes_iv_"; private static final String STORAGE_NAME = "muun-secure-storage"; private static final String MODE = "mode"; + private static final String AUDIT_TRAIL_STORAGE_NAME = "audit-trail"; + private static final String AUDIT_TRAIL_KEY = "audit-trail"; private final SharedPreferences sharedPreferences; + private final Preference> auditPreference; @Inject public SecureStoragePreferences(Context context) { sharedPreferences = context.getSharedPreferences(STORAGE_NAME, Context.MODE_PRIVATE); + final SharedPreferences auditTrailPreferences = context.getSharedPreferences( + AUDIT_TRAIL_STORAGE_NAME, + Context.MODE_PRIVATE + ); + auditPreference = RxSharedPreferences.create(auditTrailPreferences).>getObject( + AUDIT_TRAIL_KEY, + new ArrayList<>(), + new JsonListPreferenceAdapter(String.class) + ); + } + + @VisibleForTesting + SecureStoragePreferences() { + // We have unit tests over this class so we can't really use context. Those tests + // already override every method in the class, so it's safe to have these values as null. + sharedPreferences = null; + auditPreference = null; } private void initSecureStorage() { @@ -31,6 +65,9 @@ private void initSecureStorage() { } } + /** + * Fetch the IV for a given key, creating one if it doesn't exist. + */ public byte[] getAesIv(String key) { return getPersistentSecureRandomBytes(AES_IV_KEY_PREFIX + key, AES_IV_SIZE); } @@ -40,7 +77,17 @@ public byte[] getAesIv(String key) { */ public synchronized byte[] getPersistentSecureRandomBytes(String key, int size) { if (sharedPreferences.contains(key)) { - return getBytes(key); + final byte[] iv = getBytes(key); + + // We've had a few InvalidKeyExceptions that might come from invalid IVs + // So we check early and fail with some context + if (iv.length != size) { + throw new CryptographyException( + "IV for key " + key + " has size " + iv.length + " != " + size + ); + } + + return iv; } else { final byte[] bytes = RandomGenerator.getBytes(size); @@ -77,6 +124,7 @@ public boolean hasKey(String key) { */ public void wipe() { sharedPreferences.edit().clear().commit(); + auditPreference.delete(); } /** @@ -90,8 +138,8 @@ public SecureStorageMode getMode() { } /** - * @return true if this module is currently storing data under the same mode as which is - * currently operating or hasn't being initialized. + * Check whether this module is currently storing data under the same mode as which is currently + * operating or hasn't being initialized. */ public boolean isCompatibleFormat() { @@ -109,4 +157,45 @@ public void delete(String key) { .remove(key) .commit(); } + + Set getAllLabels() { + final Set result = new HashSet<>(); + for (final String label : sharedPreferences.getAll().keySet()) { + if (!label.startsWith(AES_IV_KEY_PREFIX)) { + result.add(label); + } + } + + return result; + } + + Set getAllIvLabels() { + final Set result = new HashSet<>(); + for (final String label : sharedPreferences.getAll().keySet()) { + if (label.startsWith(AES_IV_KEY_PREFIX)) { + result.add(label.replace(AES_IV_KEY_PREFIX, "")); + } + } + + return result; + } + + void recordAuditTrail(final String operation, final String label) { + try { + @SuppressWarnings("ConstantConditions") // NonNull, default value for pref is empty list + final List trail = new ArrayList<>(auditPreference.get()); + + final Instant instant = Instant.ofEpochMilli(System.currentTimeMillis()); + final ZonedDateTime now = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC); + final String timestampt = now.format(Dates.ISO_DATE_TIME_WITH_MILLIS); + trail.add(timestampt + " " + operation + " " + label); + auditPreference.setNow(trail); + } catch (final Exception e) { + Timber.e("Failed to record audit trail (" + operation + " " + label + ")", e); + } + } + + List getAuditTrail() { + return auditPreference.get(); + } } 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 d8342c7c..5be891c2 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 @@ -5,7 +5,10 @@ import rx.Observable; +import java.util.List; import java.util.NoSuchElementException; +import java.util.Set; +import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Singleton; @@ -36,7 +39,7 @@ public byte[] get(String key) { return retrieveDecrypted(key); } catch (Throwable e) { - throw new SecureStorageError(e); + throw new SecureStorageError(e, debugSnapshot()); } } @@ -57,8 +60,10 @@ public void put(String key, byte[] value) { storeEncrypted(key, value); } catch (Throwable e) { - throw new SecureStorageError(e); + throw new SecureStorageError(e, debugSnapshot()); } + + preferences.recordAuditTrail("PUT", key); } /** @@ -77,6 +82,8 @@ public Observable putAsync(String key, byte[] value) { public void delete(String key) { preferences.delete(key); keyStore.deleteEntry(key); + + preferences.recordAuditTrail("DELETE", key); } /** @@ -105,11 +112,13 @@ public boolean has(String key) { public void wipe() { preferences.wipe(); keyStore.wipe(); + + preferences.recordAuditTrail("WIPE", "*"); } private void throwIfModeInconsistent() { if (!preferences.isCompatibleFormat()) { - throw new InconsistentModeError(); + throw new InconsistentModeError(debugSnapshot()); } } @@ -122,11 +131,11 @@ private void throwIfKeyCorruptedOrMissing(String key) { } if (!hasKeyInPreferences) { - throw new SharedPreferencesCorruptedError(); + throw new SharedPreferencesCorruptedError(debugSnapshot()); } if (!hasKeyInKeystore) { - throw new KeyStoreCorruptedError(); + throw new KeyStoreCorruptedError(debugSnapshot()); } } @@ -138,11 +147,38 @@ private void storeEncrypted(String key, byte[] input) { preferences.saveBytes(keyStore.encryptData(input, key, preferences.getAesIv(key)), key); } + /** + * Take a debug snapshot of the current state of the secure storage. This is safe to + * report without compromising any user data. + */ + public DebugSnapshot debugSnapshot() { + // NEVER ever return any values from the keystore itself, only labels should get out. + + Set keystoreLabels = null; + Exception keystoreException = null; + try { + keystoreLabels = keyStore.getAllLabels(); + } catch (final Exception e) { + keystoreException = e; + } + + return new DebugSnapshot( + preferences.getMode(), + preferences.isCompatibleFormat(), + preferences.getAllLabels(), + preferences.getAllIvLabels(), + keystoreLabels, + keystoreException, + preferences.getAuditTrail() + ); + } + /** * The Android KeyStore appears to be corrupted: a key present in our Preference map is missing. */ public static class KeyStoreCorruptedError extends SecureStorageError { - public KeyStoreCorruptedError() { + public KeyStoreCorruptedError(DebugSnapshot debugSnapshot) { + super(debugSnapshot); } } @@ -150,11 +186,8 @@ public KeyStoreCorruptedError() { * The SharedPreferences bag appears to be corrupted: a key present in our KeyStore is missing. */ public static class SharedPreferencesCorruptedError extends SecureStorageError { - public SharedPreferencesCorruptedError(Throwable throwable) { - super(throwable); - } - - public SharedPreferencesCorruptedError() { + public SharedPreferencesCorruptedError(DebugSnapshot debugSnapshot) { + super(debugSnapshot); } } @@ -163,7 +196,36 @@ public SharedPreferencesCorruptedError() { * This is most likely due to a system update to Marshmallow from a previous version. */ public static class InconsistentModeError extends SecureStorageError { - public InconsistentModeError() { + public InconsistentModeError(DebugSnapshot debugSnapshot) { + super(debugSnapshot); + } + } + + public static class DebugSnapshot { + public final SecureStorageMode mode; + public final boolean isCompatible; + public final Set labelsInPrefs; + public final Set labelsWithIvInPrefs; + public @Nullable final Set labelsInKeystore; + public @Nullable final Exception keystoreException; + public final List auditTrail; + + public DebugSnapshot( + final SecureStorageMode mode, + final boolean isCompatible, + final Set labelsInPrefs, + final Set labelsWithIvInPrefs, + @Nullable final Set labelsInKeystore, + @Nullable final Exception keystoreException, + final List auditTrail + ) { + this.mode = mode; + this.isCompatible = isCompatible; + this.labelsInPrefs = labelsInPrefs; + this.labelsWithIvInPrefs = labelsWithIvInPrefs; + this.labelsInKeystore = labelsInKeystore; + this.keystoreException = keystoreException; + this.auditTrail = auditTrail; } } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/ApiMigrationsManager.kt b/android/apollo/src/main/java/io/muun/apollo/domain/ApiMigrationsManager.kt index e8ec2934..d1db8439 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/ApiMigrationsManager.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/ApiMigrationsManager.kt @@ -12,8 +12,7 @@ import rx.schedulers.Schedulers import timber.log.Timber import javax.inject.Inject -class ApiMigrationsManager -@Inject constructor( +class ApiMigrationsManager @Inject constructor( private val apiMigrationsVersionRepository: ApiMigrationsVersionRepository, private val registerInvoices: RegisterInvoicesAction, private val fetchSwapServerKeyAction: FetchSwapServerKeyAction, diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/LoggingContextManager.kt b/android/apollo/src/main/java/io/muun/apollo/domain/LoggingContextManager.kt new file mode 100644 index 00000000..a32fbc0e --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/LoggingContextManager.kt @@ -0,0 +1,33 @@ +package io.muun.apollo.domain + +import android.content.Context +import io.muun.apollo.data.logging.LoggingContext +import io.muun.apollo.data.preferences.UserRepository +import io.muun.apollo.domain.model.user.User +import io.muun.apollo.domain.utils.locale +import io.muun.common.Optional +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LoggingContextManager @Inject constructor( + private val userRepository: UserRepository, + private val context: Context +) { + + /** + * Setups Crashlytics metadata. + */ + fun setupCrashlytics() { + val maybeUser: Optional = userRepository.fetchOneOptional() + + if (!maybeUser.isPresent) { + return // If no LOGGED-IN user do nothing (we handle sign-in flow on its own) + } + + val user = maybeUser.get() + LoggingContext.configure(user.email.orElse(null), user.hid.toString()) + + LoggingContext.locale = context.locale().toString() + } +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/ContactActions.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/ContactActions.java index 593aad73..9f316d22 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/ContactActions.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/ContactActions.java @@ -13,6 +13,7 @@ import io.muun.apollo.domain.action.base.AsyncActionStore; import io.muun.apollo.domain.libwallet.LibwalletBridge; import io.muun.apollo.domain.model.Contact; +import io.muun.apollo.domain.model.MultisigContact; import io.muun.apollo.domain.model.PhoneContact; import io.muun.apollo.domain.model.user.UserPhoneNumber; import io.muun.common.crypto.hd.MuunAddress; @@ -222,17 +223,17 @@ public MuunAddress getAddressForContact(Contact contact) { return createContactAddressV1(contact); case (int) Libwallet.AddressVersionV2: - return createContactAddressV2(contact); + return createContactAddressV2(new MultisigContact(contact)); case (int) Libwallet.AddressVersionV3: - return createContactAddressV3(contact); + return createContactAddressV3(new MultisigContact(contact)); case (int) Libwallet.AddressVersionV4: - return createContactAddressV4(contact); + return createContactAddressV4(new MultisigContact(contact)); case (int) Libwallet.AddressVersionV5: default: // contact can handle higher, we can't. - return createContactAddressV5(contact); + return createContactAddressV5(new MultisigContact(contact)); } } @@ -250,31 +251,31 @@ private MuunAddress createContactAddressV1(Contact contact) { return LibwalletBridge.createAddressV1(derivedPublicKey, networkParameters); } - private MuunAddress createContactAddressV2(Contact contact) { + private MuunAddress createContactAddressV2(MultisigContact contact) { final PublicKeyPair derivedPublicKeyPair = derivePublicKeyPair(contact); return LibwalletBridge.createAddressV2(derivedPublicKeyPair, networkParameters); } - private MuunAddress createContactAddressV3(Contact contact) { + private MuunAddress createContactAddressV3(MultisigContact contact) { final PublicKeyPair derivedPublicKeyPair = derivePublicKeyPair(contact); return LibwalletBridge.createAddressV3(derivedPublicKeyPair, networkParameters); } - private MuunAddress createContactAddressV4(Contact contact) { + private MuunAddress createContactAddressV4(MultisigContact contact) { final PublicKeyPair derivedPublicKeyPair = derivePublicKeyPair(contact); return LibwalletBridge.createAddressV4(derivedPublicKeyPair, networkParameters); } - private MuunAddress createContactAddressV5(Contact contact) { + private MuunAddress createContactAddressV5(MultisigContact contact) { final PublicKeyPair derivedPublicKeyPair = derivePublicKeyPair(contact); return LibwalletBridge.createAddressV5(derivedPublicKeyPair, networkParameters); } - private PublicKeyPair derivePublicKeyPair(Contact contact) { + private PublicKeyPair derivePublicKeyPair(MultisigContact contact) { final PublicKeyPair basePublicKeyPair = contact.getPublicKeyPair(); final PublicKeyPair derivedPublicKeyPair = basePublicKeyPair diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/SigninActions.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/SigninActions.java index 8ee67da9..bbcc57d5 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/SigninActions.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/SigninActions.java @@ -1,9 +1,6 @@ package io.muun.apollo.domain.action; -import io.muun.apollo.data.logging.LoggingContext; import io.muun.apollo.data.preferences.AuthRepository; -import io.muun.apollo.data.preferences.UserRepository; -import io.muun.apollo.domain.model.user.User; import io.muun.common.Optional; import io.muun.common.model.SessionStatus; @@ -15,27 +12,13 @@ public class SigninActions { private final AuthRepository authRepository; - private final UserRepository userRepository; /** * Constructor. */ @Inject - public SigninActions(AuthRepository authRepository, UserRepository userRepository) { - + public SigninActions(AuthRepository authRepository) { this.authRepository = authRepository; - this.userRepository = userRepository; - } - - /** - * Setups Crashlytics identifiers. - */ - public void setupCrashlytics() { - final User user = userRepository.fetchOne(); - - if (user.email.isPresent()) { - LoggingContext.configure(user.email.get(), user.hid.toString()); - } } public Optional getSessionStatus() { 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 768dcb79..bcec47f9 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 @@ -1,5 +1,6 @@ package io.muun.apollo.domain.action.di; +import io.muun.apollo.domain.LoggingContextManager; import io.muun.apollo.domain.action.ContactActions; import io.muun.apollo.domain.action.CurrencyActions; import io.muun.apollo.domain.action.NotificationActions; @@ -42,6 +43,8 @@ public interface ActionComponent { SigninActions signinActions(); + LoggingContextManager loggingContextManager(); + ContactActions contactActions(); OperationActions operationActions(); diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt index e668f80a..0d449d59 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt @@ -1,7 +1,6 @@ package io.muun.apollo.domain.action.ek import io.muun.apollo.data.preferences.UserRepository -import io.muun.apollo.domain.action.base.BaseAsyncAction1 import io.muun.apollo.domain.action.base.BaseAsyncAction2 import io.muun.apollo.domain.errors.EmergencyKitInvalidCodeError import io.muun.apollo.domain.errors.EmergencyKitOldCodeError @@ -38,7 +37,7 @@ class VerifyEmergencyKitAction @Inject constructor( } else { // Not even an old code, just plain invalid: - throw EmergencyKitInvalidCodeError() + throw EmergencyKitInvalidCodeError(providedCode) } } .flatMap { diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt index b6e654db..0241f923 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt @@ -58,7 +58,7 @@ class CreateFirstSessionAction @Inject constructor( keysRepository.storeBaseMuunPublicKey(it.cosigningPublicKey) keysRepository.storeSwapServerPublicKey(it.swapServerPublicKey) - LoggingContext.configure("unknown", it.user.hid.toString()) + LoggingContext.configure(null, it.user.hid.toString()) } } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt index b818a1ba..a5c0bfe9 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt @@ -18,7 +18,7 @@ class CreateLoginSessionAction @Inject constructor( private val getFcmToken: GetFcmTokenAction, private val logoutActions: LogoutActions, private val isRootedDeviceAction: IsRootedDeviceAction, - private val firebaseInstalationIdRepository: FirebaseInstalationIdRepository + private val firebaseInstallationIdRepository: FirebaseInstalationIdRepository ) : BaseAsyncAction1() { override fun action(email: String): Observable = @@ -36,7 +36,7 @@ class CreateLoginSessionAction @Inject constructor( houstonClient.createLoginSession( fcmToken, email, - firebaseInstalationIdRepository.getBigQueryPseudoId(), + firebaseInstallationIdRepository.getBigQueryPseudoId(), isRootedDeviceAction.actionNow() ) } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt index 990b0995..fed5d6d5 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt @@ -4,6 +4,7 @@ import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.UserPreferencesRepository import io.muun.apollo.data.preferences.UserRepository import io.muun.apollo.domain.ApiMigrationsManager +import io.muun.apollo.domain.LoggingContextManager import io.muun.apollo.domain.action.ContactActions import io.muun.apollo.domain.action.OperationActions import io.muun.apollo.domain.action.SigninActions @@ -29,7 +30,7 @@ class SyncApplicationDataAction @Inject constructor( private val userRepository: UserRepository, private val contactActions: ContactActions, private val operationActions: OperationActions, - private val signinActions: SigninActions, + private val loggingContextManager: LoggingContextManager, private val syncPublicKeySet: SyncPublicKeySetAction, private val fetchNextTransactionSize: FetchNextTransactionSizeAction, private val fetchRealTimeData: FetchRealTimeDataAction, @@ -101,7 +102,7 @@ class SyncApplicationDataAction @Inject constructor( .doOnNext { userRepository.store(it.fst) userPreferencesRepository.update(it.snd) - signinActions.setupCrashlytics() + loggingContextManager.setupCrashlytics() } .toVoid() diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitInvalidCodeError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitInvalidCodeError.kt index 8ca8ab9d..f82c42e3 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitInvalidCodeError.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitInvalidCodeError.kt @@ -3,7 +3,11 @@ package io.muun.apollo.domain.errors import io.muun.apollo.data.external.UserFacingErrorMessages -open class EmergencyKitInvalidCodeError: - EmergencyKitVerificationError( - UserFacingErrorMessages.INSTANCE.emergencyKitInvalidVerificationCode() - ) +class EmergencyKitInvalidCodeError(providedCode: String) : EmergencyKitVerificationError( + UserFacingErrorMessages.INSTANCE.emergencyKitInvalidVerificationCode() +) { + + init { + metadata["providedCode"] = providedCode + } +} diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitVerificationError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitVerificationError.kt index a5b22029..30ee7598 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitVerificationError.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/EmergencyKitVerificationError.kt @@ -1,5 +1,4 @@ package io.muun.apollo.domain.errors -open class EmergencyKitVerificationError(message: String): - UserFacingError(message) +open class EmergencyKitVerificationError(message: String): UserFacingError(message) diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt index be6dbe02..fa613b67 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt @@ -13,7 +13,9 @@ class MoneyDecorationError( groupingSeparator: Char, maxFractionalDigits: Int, integerPartSize: Int, - selectionStart: Int + selectionStart: Int, + start: Int, + after: Int, ): MuunError(message), PotentialBug { init { @@ -27,6 +29,9 @@ class MoneyDecorationError( metadata["maxFractionalDigits"] = maxFractionalDigits metadata["integerPartSize"] = integerPartSize metadata["selectionStart"] = selectionStart + metadata["start"] = start + metadata["after"] = after + } } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/MuunError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/MuunError.kt index ab0a96b0..14aea73f 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/errors/MuunError.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/MuunError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.errors +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider import java.io.Serializable open class MuunError: RuntimeException { diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt b/android/apollo/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt index 82c53998..ba36a3b3 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt @@ -1,10 +1,24 @@ package io.muun.apollo.domain.errors +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider + open class SecureStorageError: MuunError { - constructor() + constructor(debugSnapshot: SecureStorageProvider.DebugSnapshot) { + attachDebugSnapshotMetadata(debugSnapshot) + } - constructor(throwable: Throwable): - super(throwable) + constructor(t: Throwable, debugSnapshot: SecureStorageProvider.DebugSnapshot): super(t) { + attachDebugSnapshotMetadata(debugSnapshot) + } + private fun attachDebugSnapshotMetadata(snapshot: SecureStorageProvider.DebugSnapshot) { + metadata["secureStorageMode"] = snapshot.mode.toString() + metadata["isCompatible"] = snapshot.isCompatible + metadata["labelsInPrefs"] = snapshot.labelsInPrefs?.joinToString(",") ?: "" + metadata["labelsWithIvInPrefs"] = snapshot.labelsWithIvInPrefs?.joinToString(",") ?: "" + metadata["labelsInKeystore"] = snapshot.labelsInKeystore?.joinToString(",") ?: "" + metadata["keystoreException"] = snapshot.keystoreException.toString() + metadata["auditTrail"] = snapshot.auditTrail?.joinToString("") ?: "" + } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/Contact.java b/android/apollo/src/main/java/io/muun/apollo/domain/model/Contact.java index f0f190b4..e2d865fa 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/Contact.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/Contact.java @@ -2,7 +2,6 @@ import io.muun.apollo.domain.model.base.HoustonIdModel; import io.muun.common.crypto.hd.PublicKey; -import io.muun.common.crypto.hd.PublicKeyPair; import io.muun.common.utils.Preconditions; import androidx.annotation.Nullable; @@ -46,10 +45,6 @@ public Contact( this.lastDerivationIndex = lastDerivationIndex; } - public PublicKeyPair getPublicKeyPair() { - return new PublicKeyPair(publicKey, cosigningPublicKey); - } - /** * Merge this Contact with an updated copy, choosing whether to keep or replace each field. */ diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/MultisigContact.kt b/android/apollo/src/main/java/io/muun/apollo/domain/model/MultisigContact.kt new file mode 100644 index 00000000..6b61def3 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/MultisigContact.kt @@ -0,0 +1,42 @@ +package io.muun.apollo.domain.model + +import io.muun.common.crypto.hd.PublicKey +import io.muun.common.crypto.hd.PublicKeyPair + +/** + * Class that represent contacts created with multisig bitcoin script. This means: + * - Contacts with addressVersion +V2 + * - CosigningPublicKey is always NOT NULL (unlike single sig contacts, with V1 address) + */ +class MultisigContact( + id: Long?, + hid: Long, + publicProfile: PublicProfile, + maxAddressVersion: Int, + publicKey: PublicKey, + cosigningPublicKey: PublicKey, + lastDerivationIndex: Long? +) : Contact( + id, + hid, + publicProfile, + maxAddressVersion, + publicKey, + cosigningPublicKey, + lastDerivationIndex +) { + + constructor(contact: Contact) : this( + contact.id, + contact.hid, + contact.publicProfile, + contact.maxAddressVersion, + contact.publicKey, + checkNotNull(contact.cosigningPublicKey), + contact.lastDerivationIndex + ) + + fun getPublicKeyPair(): PublicKeyPair { + return PublicKeyPair(publicKey, cosigningPublicKey) + } +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationUri.java b/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationUri.java index 9f7cd562..20a998fc 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationUri.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationUri.java @@ -54,13 +54,13 @@ public static OperationUri fromString(String text) throws IllegalArgumentExcepti try { return fromLnInvoice(text); } catch (IllegalArgumentException ex) { - // Not a Lighning Network raw invoice. + // Not a Lightning Network raw invoice. } try { return fromLnUri(text); } catch (IllegalArgumentException ex) { - // Not a Lighning Network URI. + // Not a Lightning Network URI. } try { diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt b/android/apollo/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt index 63748cec..4b27269e 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt @@ -3,6 +3,7 @@ package io.muun.apollo.domain.model.report import android.os.Build import io.muun.apollo.data.external.Globals import io.muun.apollo.domain.utils.getUnsupportedCurrencies +import io.muun.common.utils.Dates import io.muun.common.utils.Encodings import io.muun.common.utils.Hashes import org.threeten.bp.Instant @@ -51,7 +52,7 @@ class EmailReport private constructor(val body: String) { val body = """|Android version: ${Build.VERSION.SDK_INT} |App version: ${Globals.INSTANCE.versionName}(${Globals.INSTANCE.versionCode}) - |Date: ${now.format(DateTimeFormatter.ofPattern("dd/MM/yyyy - HH:mm:ss z")) } + |Date: ${now.format(Dates.ISO_DATE_TIME_WITH_MILLIS) } |Locale: ${locale.toString()} |SupportId: ${if (supportId != null) supportId else "Not logged in"} |ScreenPresenter: $presenterName diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/utils/Extensions.kt b/android/apollo/src/main/java/io/muun/apollo/domain/utils/Extensions.kt index b4e536bf..a6059439 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/utils/Extensions.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/utils/Extensions.kt @@ -7,6 +7,7 @@ import androidx.core.os.ConfigurationCompat import androidx.fragment.app.Fragment import io.muun.apollo.data.net.base.NetworkException import io.muun.apollo.domain.errors.MuunError +import io.muun.apollo.domain.errors.SecureStorageError import io.muun.apollo.domain.model.report.CrashReport import io.muun.common.model.Currency import io.muun.common.rx.ObservableFn @@ -53,6 +54,12 @@ fun T.applyArgs(f: Bundle.() -> Unit) = fun Throwable.isInstanceOrIsCausedByNetworkError() = isInstanceOrIsCausedByError() +/** + * Needed as inline reified functions can't be called from Java. + */ +fun Throwable.isInstanceOrIsCausedBySecureStorageError() = + isInstanceOrIsCausedByError() + inline fun Throwable.isInstanceOrIsCausedByError() = this is T || isCausedByError() 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 477133a4..f2668294 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 @@ -3,9 +3,9 @@ import io.muun.apollo.BaseTest; import io.muun.apollo.data.os.authentication.PinManager; +import io.muun.apollo.data.os.secure_storage.FakeKeyStore; +import io.muun.apollo.data.os.secure_storage.FakePreferences; import io.muun.apollo.data.os.secure_storage.SecureStorageProvider; -import io.muun.apollo.data.secure_storage.FakeKeyStore; -import io.muun.apollo.data.secure_storage.FakePreferences; import io.muun.apollo.domain.ApplicationLockManager; import io.muun.apollo.domain.selector.ChallengePublicKeySelector; diff --git a/android/apollo/src/test/java/io/muun/apollo/data/net/ModelObjectsMapperTest.kt b/android/apollo/src/test/java/io/muun/apollo/data/net/ModelObjectsMapperTest.kt index eeaaa02d..73292b36 100644 --- a/android/apollo/src/test/java/io/muun/apollo/data/net/ModelObjectsMapperTest.kt +++ b/android/apollo/src/test/java/io/muun/apollo/data/net/ModelObjectsMapperTest.kt @@ -1,10 +1,8 @@ package io.muun.apollo.data.net import io.muun.apollo.BaseTest -import io.muun.apollo.data.secure_storage.SecureStorageProviderTest import io.muun.apollo.data.serialization.SerializationUtils import io.muun.common.MuunFeatureJson -import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.* import org.junit.Test diff --git a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/PinManagerTest.java b/android/apollo/src/test/java/io/muun/apollo/data/os/authentication/PinManagerTest.java similarity index 88% rename from android/apollo/src/test/java/io/muun/apollo/data/secure_storage/PinManagerTest.java rename to android/apollo/src/test/java/io/muun/apollo/data/os/authentication/PinManagerTest.java index a263ceb8..5a8b12fa 100644 --- a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/PinManagerTest.java +++ b/android/apollo/src/test/java/io/muun/apollo/data/os/authentication/PinManagerTest.java @@ -1,7 +1,8 @@ -package io.muun.apollo.data.secure_storage; +package io.muun.apollo.data.os.authentication; import io.muun.apollo.BaseTest; -import io.muun.apollo.data.os.authentication.PinManager; +import io.muun.apollo.data.os.secure_storage.FakeKeyStore; +import io.muun.apollo.data.os.secure_storage.FakePreferences; import io.muun.apollo.data.os.secure_storage.SecureStorageProvider; import org.junit.Before; diff --git a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/FakeKeyStore.java b/android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/FakeKeyStore.java similarity index 91% rename from android/apollo/src/test/java/io/muun/apollo/data/secure_storage/FakeKeyStore.java rename to android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/FakeKeyStore.java index c198223a..b9f27a40 100644 --- a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/FakeKeyStore.java +++ b/android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/FakeKeyStore.java @@ -1,6 +1,4 @@ -package io.muun.apollo.data.secure_storage; - -import io.muun.apollo.data.os.secure_storage.KeyStoreProvider; +package io.muun.apollo.data.os.secure_storage; import android.content.Context; import androidx.core.util.Pair; diff --git a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/FakePreferences.java b/android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/FakePreferences.java similarity index 73% rename from android/apollo/src/test/java/io/muun/apollo/data/secure_storage/FakePreferences.java rename to android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/FakePreferences.java index b7a56706..b4f65dd7 100644 --- a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/FakePreferences.java +++ b/android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/FakePreferences.java @@ -1,14 +1,12 @@ -package io.muun.apollo.data.secure_storage; +package io.muun.apollo.data.os.secure_storage; -import io.muun.apollo.data.os.secure_storage.SecureStorageMode; -import io.muun.apollo.data.os.secure_storage.SecureStoragePreferences; import io.muun.common.utils.RandomGenerator; -import android.content.Context; - +import java.util.ArrayList; import java.util.HashMap; - -import static org.mockito.Mockito.mock; +import java.util.HashSet; +import java.util.List; +import java.util.Set; public class FakePreferences extends SecureStoragePreferences { private final HashMap map = new HashMap<>(); @@ -16,7 +14,7 @@ public class FakePreferences extends SecureStoragePreferences { private SecureStorageMode lastModeUsedInSave; public FakePreferences() { - super(mock(Context.class)); + super(); } public byte[] getAesIv(String key) { @@ -68,4 +66,23 @@ public void delete(String key) { public void setMode(SecureStorageMode mode) { this.mode = mode; } + + @Override + List getAuditTrail() { + return new ArrayList<>(); + } + + @Override + Set getAllIvLabels() { + return new HashSet<>(); + } + + @Override + Set getAllLabels() { + return new HashSet<>(); + } + + @Override + void recordAuditTrail(final String operation, final String label) { + } } diff --git a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/SecureStorageProviderTest.java b/android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/SecureStorageProviderTest.java similarity index 96% rename from android/apollo/src/test/java/io/muun/apollo/data/secure_storage/SecureStorageProviderTest.java rename to android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/SecureStorageProviderTest.java index 6ad2f88e..4abc71d5 100644 --- a/android/apollo/src/test/java/io/muun/apollo/data/secure_storage/SecureStorageProviderTest.java +++ b/android/apollo/src/test/java/io/muun/apollo/data/os/secure_storage/SecureStorageProviderTest.java @@ -1,8 +1,6 @@ -package io.muun.apollo.data.secure_storage; +package io.muun.apollo.data.os.secure_storage; import io.muun.apollo.BaseTest; -import io.muun.apollo.data.os.secure_storage.SecureStorageMode; -import io.muun.apollo.data.os.secure_storage.SecureStorageProvider; import org.junit.Before; import org.junit.Test; diff --git a/android/apolloui/build.gradle b/android/apolloui/build.gradle index 1c1fc6ca..45b68a90 100644 --- a/android/apolloui/build.gradle +++ b/android/apolloui/build.gradle @@ -80,8 +80,8 @@ android { applicationId "io.muun.apollo" minSdkVersion 19 targetSdkVersion 30 - versionCode 902 - versionName "49.2" + versionCode 903 + versionName "49.3" // Needed to make sure these classes are available in the main DEX file for API 19 // See: https://spin.atomicobject.com/2018/07/16/support-kitkat-multidex/ diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/domain/model/OperationUriTest.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/domain/model/OperationUriTest.kt index ae532171..8c0a6de6 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/domain/model/OperationUriTest.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/domain/model/OperationUriTest.kt @@ -3,8 +3,9 @@ package io.muun.apollo.domain.model import io.muun.apollo.data.external.Globals import io.muun.apollo.domain.BaseUnitTest import io.muun.common.bitcoinj.NetworkParametersHelper -import org.junit.Assert -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mockito.doReturn @@ -24,6 +25,7 @@ class OperationUriTest: BaseUnitTest() { private val legacyAddress = "3AXHY3dJU1z9YkU5o1GKiLomCcdySZhBnj" private val bech32Address = "BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2" + private val taprootAddress = "bc1ps3y85gxp3wxyezkcvsww0cylfng209t896fsxxw2c8r2tju0fajseqrglx" private val invoice = "lnbc340n1p0r3px4pp5xf6h65d75sjwfd0lg6x37238chfxa8j5e2txn2cu8rwhqll9s3s" + "qdzq2pshjmt9de6zqen0wgsrxdpqwp5hsetvwvsxzapqwdshgmmndp5hxtnsd3skxefwxqzjccqp2sp53uldkk4r" + "zafm00pv9sm7u57caycgn0qs52fxvkce96w4349x9pyqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rw" + @@ -42,6 +44,8 @@ class OperationUriTest: BaseUnitTest() { @Test fun fromString() { + // TODO add support and test for taproot addresses + var uri = OperationUri.fromString(bech32Address) assertTrue(uri.isBitcoin) assertEquals(bech32Address, uri.bitcoinAddress.get()) @@ -115,6 +119,8 @@ class OperationUriTest: BaseUnitTest() { assertEquals("1.2", btcUriWithAmountAndCurrency.getParam(OperationUri.MUUN_AMOUNT).get()) assertEquals("btc", btcUriWithAmountAndCurrency.getParam(OperationUri.MUUN_CURRENCY).get()) + // TODO add test for BOLT11_INVOICE_PARAM + val lnUri = OperationUri.fromString("muun:$invoice") assertTrue(lnUri.isLn) assertEquals(invoice, lnUri.lnInvoice.get()) @@ -154,4 +160,7 @@ class OperationUriTest: BaseUnitTest() { val uri = OperationUri.fromString(uriString) assertTrue(uri.isBitcoin) } + + // TODO: What about bitcoin: or muun:?? + // TODO: What about muun:? E.g muun:bitcoin:
} \ No newline at end of file diff --git a/android/apolloui/src/main/AndroidManifest.xml b/android/apolloui/src/main/AndroidManifest.xml index 4881ab82..dccb6511 100644 --- a/android/apolloui/src/main/AndroidManifest.xml +++ b/android/apolloui/src/main/AndroidManifest.xml @@ -107,6 +107,11 @@ android:parentActivityName=".presentation.ui.home.HomeActivity" android:windowSoftInputMode="adjustResize"> + @@ -363,8 +368,8 @@ 0) { - timeText = ctx.getString(R.string.new_operation_invoice_exp_weeks, weeks, days); - - } else if (days > 0) { - timeText = ctx.getString(R.string.new_operation_invoice_exp_days, days, hours); - - } else if (hours > 0) { - timeText = ctx.getString(R.string.new_operation_invoice_exp_hours, hours, minutes); - - } else if (minutes > 0) { - timeText = ctx.getString(R.string.new_operation_invoice_exp_minutes, minutes, seconds); - - } else { - timeText = ctx.getString(R.string.new_operation_invoice_exp_seconds, minutes, seconds); - } - - onTextUpdate(remainingSeconds, timeText); - } - - /** - * This won't necessary be a DIFFERENT text on each call. I know, see onTickSeconds javadoc. - */ - protected abstract void onTextUpdate(long remainingSeconds, CharSequence text); - -} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt index b8f01419..97a994cc 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/AlertDialogExtension.kt @@ -2,10 +2,12 @@ package io.muun.apollo.presentation.ui.activity.extension import android.app.AlertDialog import android.content.DialogInterface +import androidx.annotation.StringRes import butterknife.BindColor import io.muun.apollo.R import io.muun.apollo.presentation.ui.base.ActivityExtension import io.muun.apollo.presentation.ui.base.di.PerActivity +import io.muun.apollo.presentation.ui.utils.getStyledString import rx.functions.Action0 import javax.inject.Inject @@ -41,7 +43,14 @@ class AlertDialogExtension @Inject constructor() : ActivityExtension() { /** * Show a simple, standard muun error dialog. */ - fun showErrorDialog(msg: String, followupAction: Action0? = null, onDismiss: Action0? = null) { + fun showErrorDialog(@StringRes resId: Int, followup: Action0? = null, onDismiss: Action0? = null) { + showErrorDialog(activity.getStyledString(resId), followup, onDismiss) + } + + /** + * Show a simple, standard muun error dialog. + */ + fun showErrorDialog(msg: CharSequence, followupAction: Action0? = null, onDismiss: Action0? = null) { val builder = MuunDialog.Builder() .layout(R.layout.dialog_custom_layout) .message(msg) 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 5c605797..ee90237b 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 @@ -2,6 +2,7 @@ import io.muun.apollo.data.os.execution.ExecutionTransformerFactory; import io.muun.apollo.domain.ApplicationLockManager; +import io.muun.apollo.domain.utils.ExtensionsKt; import io.muun.apollo.presentation.analytics.Analytics; import io.muun.apollo.presentation.analytics.AnalyticsEvent; import io.muun.apollo.presentation.app.Navigator; @@ -16,6 +17,8 @@ import android.widget.EditText; import icepick.State; import rx.Single; +import rx.exceptions.OnErrorNotImplementedException; +import timber.log.Timber; import javax.inject.Inject; @@ -175,12 +178,22 @@ private class BoundLockOverlayListener implements MuunLockOverlay.LockOverlayLis public void onPinEntered(String pin) { Single.fromCallable(() -> lockManager.tryUnlockWithPin(pin)) .compose(executionTransformerFactory.getSingleAsyncExecutor()) - .subscribe((isUnlocked) -> { + .subscribe(isUnlocked -> { if (isUnlocked) { onUnlockAttemptSuccess(); } else { onUnlockAttemptFailure(); } + }, throwable -> { + // 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)) { + // IDKW but we can't throw other error than this one, go figure + throw new OnErrorNotImplementedException(throwable); + } else { + Timber.e(throwable); // Probably redundant, should already be logged + lockOverlay.reportError(null); + } }); } 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 708294be..54614839 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,8 +1,10 @@ package io.muun.apollo.presentation.ui.base; +import io.muun.apollo.R; import io.muun.apollo.data.logging.LoggingContext; import io.muun.apollo.domain.action.UserActions; import io.muun.apollo.domain.errors.BugDetected; +import io.muun.apollo.domain.errors.SecureStorageError; import io.muun.apollo.domain.utils.ExtensionsKt; import io.muun.apollo.presentation.app.ApolloApplication; import io.muun.apollo.presentation.app.di.ApplicationComponent; @@ -35,6 +37,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.MenuRes; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.fragment.app.DialogFragment; import butterknife.ButterKnife; import icepick.Icepick; @@ -121,13 +124,26 @@ protected void setUpExtensions() { @Override @CallSuper protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + try { + super.onCreate(savedInstanceState); - Icepick.restoreInstanceState(this, savedInstanceState); + Icepick.restoreInstanceState(this, savedInstanceState); - setUpLayout(); - initializePresenter(savedInstanceState); - initializeUi(); + setUpLayout(); + initializePresenter(savedInstanceState); + initializeUi(); + + } catch (SecureStorageError e) { + // Avoid crashing on weird Android Keystore errors. Redundantly report error with + // extra metadata and offer some explanation to the user with the option to send + // error report email for maximum exposure and data points. + + Timber.e(e); + showErrorDialog( + R.string.secure_storage_error_avoid_crash_non_logout, + () -> presenter.sendErrorReport(e) + ); + } } @Override @@ -176,17 +192,29 @@ protected void onSaveInstanceState(Bundle outState) { @Override @CallSuper protected void onResume() { - super.onResume(); - LoggingContext.setLocale(ExtensionsKt.locale(this).toString()); - if (blockScreenshots()) { - screenshotBlockExtension.startBlockingScreenshots(); + try { + super.onResume(); + LoggingContext.setLocale(ExtensionsKt.locale(this).toString()); + if (blockScreenshots()) { + screenshotBlockExtension.startBlockingScreenshots(); + } + + userActions.updateContactsPermissionState( + allPermissionsGranted(android.Manifest.permission.READ_CONTACTS) + ); + presenter.setUp(getArgumentsBundle()); + presenter.afterSetUp(); + } catch (SecureStorageError e) { + // Avoid crashing on weird Android Keystore errors. Redundantly report error with + // extra metadata and offer some explanation to the user with the option to send + // error report email for maximum exposure and data points. + + Timber.e(e); + showErrorDialog( + R.string.secure_storage_error_avoid_crash_non_logout, + () -> presenter.sendErrorReport(e) + ); } - - userActions.updateContactsPermissionState( - allPermissionsGranted(android.Manifest.permission.READ_CONTACTS) - ); - presenter.setUp(getArgumentsBundle()); - presenter.afterSetUp(); } @Override @@ -437,12 +465,27 @@ public void showPlayServicesDialog(Action1 showDialog) { showDialog.call(this); } + /** + * Show a simple, standard muun error dialog. + */ + @Override + public void showErrorDialog(@StringRes int resId) { + showErrorDialog(resId, null); + } + + /** + * Show a simple, standard muun error dialog. + */ + @Override + public void showErrorDialog(@StringRes int resId, Action0 followupAction) { + alertDialogExtension.showErrorDialog(resId, followupAction, null); + } /** * Show a simple, standard muun error dialog. */ @Override - public void showErrorDialog(String errorMsg) { + public void showErrorDialog(CharSequence errorMsg) { showErrorDialog(errorMsg, null, null); } @@ -450,7 +493,7 @@ public void showErrorDialog(String errorMsg) { * Show a simple, standard muun error dialog. */ @Override - public void showErrorDialog(String errorMsg, Action0 followupAction) { + public void showErrorDialog(CharSequence errorMsg, Action0 followupAction) { showErrorDialog(errorMsg, followupAction, null); } @@ -458,8 +501,8 @@ public void showErrorDialog(String errorMsg, Action0 followupAction) { * Show a simple, standard muun error dialog. */ @Override - public void showErrorDialog(String errorMsg, Action0 followupAction, Action0 onDismissAction) { - alertDialogExtension.showErrorDialog(errorMsg, followupAction, onDismissAction); + public void showErrorDialog(CharSequence errorMsg, Action0 followup, Action0 onDismiss) { + alertDialogExtension.showErrorDialog(errorMsg, followup, onDismiss); } /** @@ -485,8 +528,8 @@ public void showSnackBar(int messageResId) { /** * Show an indefinite snack bar, of custom height. */ - public void showSnackBar(int messageResId, boolean dismissable, Float height) { - snackBarExtension.showSnackBarIndefinite(messageResId, dismissable, height); + public void showSnackBar(int messageResId, boolean dismissible, Float height) { + snackBarExtension.showSnackBarIndefinite(messageResId, dismissible, height); } /** 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 5d6660bd..44ad6a79 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 @@ -27,6 +27,7 @@ import androidx.annotation.MenuRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import butterknife.ButterKnife; @@ -251,7 +252,23 @@ public void showPlayServicesDialog(Action1 showDialog) { * Show a simple, standard muun error dialog. */ @Override - public void showErrorDialog(String errorMsg) { + public void showErrorDialog(@StringRes int resId) { + showErrorDialog(resId, null); + } + + /** + * Show a simple, standard muun error dialog. + */ + @Override + public void showErrorDialog(@StringRes int resId, Action0 followupAction) { + getParentActivity().showErrorDialog(resId, followupAction); + } + + /** + * Show a simple, standard muun error dialog. + */ + @Override + public void showErrorDialog(CharSequence errorMsg) { showErrorDialog(errorMsg, null, null); } @@ -259,7 +276,7 @@ public void showErrorDialog(String errorMsg) { * Show a simple, standard muun error dialog. */ @Override - public void showErrorDialog(String errorMsg, Action0 followupAction) { + public void showErrorDialog(CharSequence errorMsg, Action0 followupAction) { getParentActivity().showErrorDialog(errorMsg, followupAction); } @@ -267,8 +284,8 @@ public void showErrorDialog(String errorMsg, Action0 followupAction) { * Show a simple, standard muun error dialog. */ @Override - public void showErrorDialog(String errorMsg, Action0 followupAction, Action0 onDismissAction) { - getParentActivity().showErrorDialog(errorMsg, followupAction, onDismissAction); + public void showErrorDialog(CharSequence errorMsg, Action0 followup, Action0 onDismiss) { + getParentActivity().showErrorDialog(errorMsg, followup, onDismiss); } @Override diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java index a918dddc..e8bba7db 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java @@ -298,7 +298,7 @@ protected boolean maybeHandleFatalError(Throwable error) { } else { // Handle session expired "gracefully" view.showErrorDialog( - getContext().getString(R.string.error_expired_session), + R.string.error_expired_session, () -> { // Do nothing } @@ -307,7 +307,8 @@ protected boolean maybeHandleFatalError(Throwable error) { } else if (error instanceof SecureStorageError && isRecoverableWallet) { - view.showErrorDialog(getContext().getString(R.string.secure_storage_error), + view.showErrorDialog( + R.string.secure_storage_error, () -> navigator.navigateToLogout(getContext()) ); @@ -331,7 +332,7 @@ protected boolean maybeHandleNonFatalError(Throwable error) { return true; } else if (ExtensionsKt.isInstanceOrIsCausedByNetworkError(error)) { - view.showErrorDialog(getContext().getString(R.string.network_error_message)); + view.showErrorDialog(R.string.network_error_message); return true; } else if (error instanceof TooManyRequestsError) { @@ -522,7 +523,8 @@ private void showErrorReportDialog(Throwable error, boolean standalone) { view.showDialog(muunDialog); } - protected void sendErrorReport(Throwable error) { + @Override + public void sendErrorReport(Throwable error) { final CrashReport report = CrashReportBuilder.INSTANCE.build(error); analytics.attachAnalyticsMetadata(report); diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseView.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseView.java index 8309db9f..cc117bd4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseView.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseView.java @@ -5,6 +5,7 @@ import android.app.Activity; import android.content.Context; import android.os.Bundle; +import androidx.annotation.StringRes; import rx.functions.Action0; import rx.functions.Action1; @@ -12,27 +13,71 @@ public interface BaseView { + /** + * Get Android context. + */ @NotNull Context getViewContext(); + /** + * Safely try and show a simple test Toast. + * You should not call this method from any thread other than Android's MainThread. It's a + * coding error. We're just adding a safety measure to avoid fatal crashes in weird/edge + * situations. + */ void showTextToast(String text); - void showErrorDialog(String errorMessage); + /** + * Show a simple, standard muun error dialog. + */ + void showErrorDialog(@StringRes int resourceId); // JAVAAAA Why don't u default arguments ??!!? - void showErrorDialog(String errorMessage, Action0 followupAction); + /** + * Show a simple, standard muun error dialog. + */ + void showErrorDialog(@StringRes int resourceId, Action0 followupAction); + + /** + * Show a simple, standard muun error dialog. + */ + void showErrorDialog(CharSequence errorMessage); + + // JAVAAAA Why don't u default arguments ??!!? + /** + * Show a simple, standard muun error dialog. + */ + void showErrorDialog(CharSequence errorMessage, Action0 followupAction); // JAVAAAA Why don't u default arguments ??!!? - void showErrorDialog(String errorMessage, Action0 followupAction, Action0 onDismissAction); + /** + * Show a simple, standard muun error dialog. + */ + void showErrorDialog(CharSequence errorMessage, Action0 followup, Action0 onDismiss); + /** + * Immediately finish this View's associated Activity. + */ void finishActivity(); + /** + * Get this view's arguments/Intent bundle. + */ @NotNull Bundle getArgumentsBundle(); + /** + * Show an AlertDialog. + */ void showDialog(MuunDialog dialog); + /** + * Dismiss currently dislayed AlertDialog, if any. + */ void dismissDialog(); + /** + * Display a dialog that allows the user to install Google Play Services. + */ void showPlayServicesDialog(Action1 activityAction1); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/Presenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/Presenter.java index cc572399..02933381 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/Presenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/Presenter.java @@ -7,18 +7,49 @@ public interface Presenter extends ParentPresenter { + + /** + * Override this method to add any initialization logic that needs to happen at view creation, + * e.g at Activity/Fragment#onCreate. + */ void onViewCreated(@Nullable Bundle savedInstanceState); + /** + * Override this method to add any initialization logic that the presenter needs. + */ void setUp(@NotNull Bundle arguments); + /** + * Override this method to add any logic that the presenter needs to run right AFTER + * initialization success. + */ void afterSetUp(); + /** + * Override this method to add any clean up logic that the presenter needs. + */ void tearDown(); + /** + * Saves the state of the presenter. + */ void saveState(@NotNull Bundle state); + /** + * Restores the state of the presenter. + */ void restoreState(@Nullable Bundle state); + /** + * Set presenter's view (e.g activity or fragment). + */ void setView(@NotNull ViewT view); + /** + * Send an email with a detailed error report, if the device has an email client/app installed. + * Otherwise show a dialog with an explanatory comment and offer the possibility of copying + * error report to the clipboard. + */ + void sendErrorReport(Throwable throwable); + } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragment.kt index 77a15c9f..7e6e4228 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragment.kt @@ -31,7 +31,7 @@ import io.muun.common.utils.BitcoinUtils import org.threeten.bp.ZonedDateTime import kotlin.math.abs -class HomeFragment: SingleFragment(), HomeView { +class HomeFragment: SingleFragment(), HomeFragmentView { companion object { private const val NEW_OP_ANIMATION_WINDOW = 15L // In Seconds @@ -204,7 +204,7 @@ class HomeFragment: SingleFragment(), HomeView { } } - override fun setState(homeState: HomePresenter.HomeState) { + override fun setState(homeState: HomeFragmentPresenter.HomeState) { balanceView.setBalance(homeState) setChevronAnimation(homeState.utxoSetState) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeParentPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentParentPresenter.kt similarity index 76% rename from android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeParentPresenter.kt rename to android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentParentPresenter.kt index 13425dcb..291a1ff4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeParentPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentParentPresenter.kt @@ -2,7 +2,7 @@ package io.muun.apollo.presentation.ui.fragments.home import io.muun.apollo.presentation.ui.base.ParentPresenter -interface HomeParentPresenter : ParentPresenter { +interface HomeFragmentParentPresenter : ParentPresenter { fun navigateToSecurityCenter() diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomePresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentPresenter.kt similarity index 97% rename from android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomePresenter.kt rename to android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentPresenter.kt index 29fca8e4..e4fa6315 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomePresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentPresenter.kt @@ -25,7 +25,7 @@ import javax.inject.Inject @PerFragment -class HomePresenter @Inject constructor( +class HomeFragmentPresenter @Inject constructor( private val paymentContextSel: PaymentContextSelector, private val bitcoinUnitSel: BitcoinUnitSelector, private val userPreferencesSelector: UserPreferencesSelector, @@ -35,7 +35,7 @@ class HomePresenter @Inject constructor( private val utxoSetStateSelector: UtxoSetStateSelector, private val featureStatusSel: FeatureStatusSelector, private val blockchainHeightSel: BlockchainHeightSelector -) : SingleFragmentPresenter() { +) : SingleFragmentPresenter() { @State @JvmField diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentView.kt similarity index 73% rename from android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeView.kt rename to android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentView.kt index 7698aef0..faa53caf 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/home/HomeFragmentView.kt @@ -4,9 +4,9 @@ import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.model.Operation import io.muun.apollo.presentation.ui.base.BaseView -interface HomeView : BaseView { +interface HomeFragmentView : BaseView { - fun setState(homeState: HomePresenter.HomeState) + fun setState(homeState: HomeFragmentPresenter.HomeState) fun setNewOp(newOp: Operation, bitcoinUnit: BitcoinUnit) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/manual_fee/ManualFeeFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/manual_fee/ManualFeeFragment.kt index e0903316..4bbd062c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/manual_fee/ManualFeeFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/manual_fee/ManualFeeFragment.kt @@ -99,6 +99,7 @@ class ManualFeeFragment : SingleFragment(), ManualFeeView { val minMempoolFeeRateInVBytes = state.resolved.paymentContext.minFeeRateInSatsPerVByte val minFeeRateInVBytes = max(minMempoolFeeRateInVBytes, minProtocolFeeRateInVBytes) + // TODO currently forgets input currency, which is important for amount display val feeData = state.calculateFee(feeRateInSatsPerVByte) // 2. Always show analysis data diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/recommended_fee/RecommendedFeeFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/recommended_fee/RecommendedFeeFragment.kt index b1bf19a9..5e78c493 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/recommended_fee/RecommendedFeeFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/recommended_fee/RecommendedFeeFragment.kt @@ -149,6 +149,8 @@ class RecommendedFeeFragment : SingleFragment(), Recomm private fun bindFeeOption(feeOptionItem: FeeOptionItem, state: EditFeeState, confTarget: Long) { val feeRateInVBytes = state.minFeeRateForTarget(confTarget) + + // TODO currently forgets input currency, which is important for amount display val feeData = state.calculateFee(feeRateInVBytes) feeOptionItem.setBitcoinUnit(mBitcoinUnit) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/verification_code/VerificationCodeFragment.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/verification_code/VerificationCodeFragment.java index eb72135e..74cff4ba 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/verification_code/VerificationCodeFragment.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/verification_code/VerificationCodeFragment.java @@ -24,6 +24,8 @@ import butterknife.OnClick; import icepick.State; +import static io.muun.common.utils.Dates.MINUTE_IN_SECONDS; + public class VerificationCodeFragment extends SingleFragment implements VerificationCodeView { @@ -117,6 +119,7 @@ public boolean onBackPressed() { return true; } + @Override public void setLoading(boolean isLoading) { verificationCode.setEnabled(!isLoading); continueButton.setLoading(isLoading); @@ -227,8 +230,8 @@ private class ResendCountdownTimer extends MuunCountdownTimer { @Override public void onTickSeconds(long remainingSeconds) { - final long minutes = remainingSeconds / 60; - final long seconds = remainingSeconds % 60; + final long minutes = remainingSeconds / MINUTE_IN_SECONDS; + final long seconds = remainingSeconds % MINUTE_IN_SECONDS; final String text = String.format(countdownTextFormat, minutes, seconds); countdownText.setText(text); 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 73fa3fb8..0cfa8b8a 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 @@ -1,10 +1,10 @@ package io.muun.apollo.presentation.ui.home; import io.muun.apollo.data.async.tasks.TaskScheduler; +import io.muun.apollo.domain.LoggingContextManager; import io.muun.apollo.domain.SignupDraftManager; import io.muun.apollo.domain.action.ContactActions; import io.muun.apollo.domain.action.NotificationActions; -import io.muun.apollo.domain.action.SigninActions; import io.muun.apollo.domain.action.realtime.FetchRealTimeDataAction; import io.muun.apollo.domain.model.UserActivatedFeatureStatus; import io.muun.apollo.domain.selector.FeatureStatusSelector; @@ -12,7 +12,7 @@ import io.muun.apollo.presentation.ui.base.BasePresenter; import io.muun.apollo.presentation.ui.base.di.PerActivity; import io.muun.apollo.presentation.ui.bundler.CurrencyUnitBundler; -import io.muun.apollo.presentation.ui.fragments.home.HomeParentPresenter; +import io.muun.apollo.presentation.ui.fragments.home.HomeFragmentParentPresenter; import io.muun.apollo.presentation.ui.fragments.operations.OperationsCache; import android.Manifest; @@ -28,9 +28,9 @@ import javax.validation.constraints.NotNull; @PerActivity -public class HomePresenter extends BasePresenter implements HomeParentPresenter { +public class HomePresenter extends BasePresenter implements HomeFragmentParentPresenter { - private final SigninActions signinActions; + private final LoggingContextManager loggingContextManager; private final ContactActions contactActions; private final NotificationActions notificationActions; private final UserSelector userSel; @@ -52,7 +52,7 @@ public class HomePresenter extends BasePresenter implements HomeParent * Creates a home presenter. */ @Inject - public HomePresenter(SigninActions signinActions, + public HomePresenter(LoggingContextManager loggingContextManager, ContactActions contactActions, NotificationActions notificationActions, UserSelector userSel, @@ -62,7 +62,7 @@ public HomePresenter(SigninActions signinActions, FetchRealTimeDataAction fetchRealTimeData, OperationsCache operationsCache) { - this.signinActions = signinActions; + this.loggingContextManager = loggingContextManager; this.contactActions = contactActions; this.userSel = userSel; this.featureStatusSel = featureStatusSel; @@ -80,7 +80,7 @@ public void onActivityCreated() { assertGooglePlayServicesPresent(); taskScheduler.scheduleAllTasks(); - signinActions.setupCrashlytics(); + loggingContextManager.setupCrashlytics(); signupDraftManager.clear(); // if we're here, we're 100% sure signup was successful operationsCache.start(); 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 bfb1e87e..bb2d32a6 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 @@ -28,7 +28,7 @@ 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.InvoiceExpirationCountdownTimer +import io.muun.apollo.presentation.ui.MuunCountdownTimer import io.muun.apollo.presentation.ui.activity.extension.MuunDialog import io.muun.apollo.presentation.ui.base.SingleFragmentActivity import io.muun.apollo.presentation.ui.base.di.PerActivity @@ -40,6 +40,7 @@ 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.ConfirmStateViewModel import io.muun.apollo.presentation.ui.new_operation.NewOperationView.Receiver +import io.muun.apollo.presentation.ui.utils.NewOperationInvoiceFormatter import io.muun.apollo.presentation.ui.utils.StyledStringRes import io.muun.apollo.presentation.ui.utils.UiUtils import io.muun.apollo.presentation.ui.utils.getStyledString @@ -190,7 +191,7 @@ class NewOperationActivity : SingleFragmentActivity(), Ne @JvmField var displayInAlternateCurrency: Boolean = false - private var countdownTimer: InvoiceExpirationCountdownTimer? = null + private var countdownTimer: MuunCountdownTimer? = null override fun inject() { component.inject(this) @@ -740,16 +741,20 @@ class NewOperationActivity : SingleFragmentActivity(), Ne finishActivity() } - private fun buildCountDownTimer(remainingMillis: Long): InvoiceExpirationCountdownTimer { - return object : InvoiceExpirationCountdownTimer(this, remainingMillis) { + private fun buildCountDownTimer(remainingMillis: Long): MuunCountdownTimer { + return object : MuunCountdownTimer(remainingMillis) { - override fun onTextUpdate(remainingSeconds: Long, timeText: CharSequence) { - val prefixText = ctx.getString(R.string.new_operation_invoice_exp_prefix) + + override fun onTickSeconds(remainingSeconds: Long) { + val context = this@NewOperationActivity + val timeText= NewOperationInvoiceFormatter(context).formatSeconds(remainingSeconds) + + val prefixText = getString(R.string.new_operation_invoice_exp_prefix) val text = TextUtils.concat(prefixText, " ", timeText) val richText = RichText(text) if (remainingSeconds < INVOICE_EXPIRATION_WARNING_TIME_IN_SECONDS) { - richText.setForegroundColor(ContextCompat.getColor(ctx, R.color.red)) + richText.setForegroundColor(ContextCompat.getColor(context, R.color.red)) } invoiceExpirationCountdown.text = text 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 9a4d8ec8..51ff91ef 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 @@ -147,7 +147,6 @@ protected void onResume() { camera.startCamera(findBackFacingCamera()); } - @SuppressWarnings("deprecation") private int findBackFacingCamera() { final int numberOfCameras = Camera.getNumberOfCameras(); @@ -228,6 +227,10 @@ private String sanitizeScannedText(String text) { .substring(0, Math.min(500, replacedText.length())); } + /** + * Handle enable camera permission click. Request OS permissions use device Camera to be granted + * to this application. + */ public void onGrantPermissionClick() { requestPermissions(this, Manifest.permission.CAMERA); presenter.reportCameraPermissionAsked(); 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 74506d3b..049db21f 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 @@ -32,7 +32,6 @@ class SelectAmountActivity : BaseActivity(), SelectAmount private const val PRE_SELECTED_AMOUNT = "SELECTED_AMOUNT" private const val SAT_SELECTED_AS_CURRENCY = "sat_selected_as_currency" - fun getSelectAddressAmountIntent(context: Context, amount: MonetaryAmount? = null, satSelectedAsCurrency: Boolean diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt index 4dc63c70..3ec886e9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt @@ -11,10 +11,11 @@ import icepick.State import io.muun.apollo.R import io.muun.apollo.domain.libwallet.DecodedInvoice import io.muun.apollo.domain.model.BitcoinUnit -import io.muun.apollo.presentation.ui.InvoiceExpirationCountdownTimer +import io.muun.apollo.presentation.ui.MuunCountdownTimer import io.muun.apollo.presentation.ui.new_operation.TitleAndDescriptionDrawer import io.muun.apollo.presentation.ui.select_amount.SelectAmountActivity import io.muun.apollo.presentation.ui.show_qr.QrFragment +import io.muun.apollo.presentation.ui.utils.ReceiveLnInvoiceFormatter import io.muun.apollo.presentation.ui.view.EditAmountItem import io.muun.apollo.presentation.ui.view.ExpirationTimeItem import io.muun.apollo.presentation.ui.view.HiddenSection @@ -68,7 +69,7 @@ class LnInvoiceQrFragment : QrFragment(), @JvmField var satSelectedAsCurrency = false - private var countdownTimer: InvoiceExpirationCountdownTimer? = null + private var countdownTimer: MuunCountdownTimer? = null override fun inject() { component.inject(this) @@ -196,11 +197,15 @@ class LnInvoiceQrFragment : QrFragment(), } } - private fun buildCountDownTimer(remainingMillis: Long): InvoiceExpirationCountdownTimer { + private fun buildCountDownTimer(remainingMillis: Long): MuunCountdownTimer { - return object : InvoiceExpirationCountdownTimer(context, remainingMillis) { - override fun onTextUpdate(remainingSeconds: Long, text: CharSequence) { - expirationTimeItem.setExpirationTime(text) + return object : MuunCountdownTimer(remainingMillis) { + + override fun onTickSeconds(remainingSeconds: Long) { + // Using minimalistic pattern/display/formatting to better fit in small screen space + expirationTimeItem.setExpirationTime( + ReceiveLnInvoiceFormatter().formatSeconds(remainingSeconds) + ) } override fun onFinish() { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ConfirmationTimeFormatter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ConfirmationTimeFormatter.kt index 36bfac67..a2c6a1c4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ConfirmationTimeFormatter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ConfirmationTimeFormatter.kt @@ -2,26 +2,26 @@ package io.muun.apollo.presentation.ui.utils import android.content.Context import io.muun.apollo.R +import io.muun.common.utils.Dates.HOUR_IN_SECONDS +import io.muun.common.utils.Dates.MINUTE_IN_SECONDS class ConfirmationTimeFormatter(val context: Context) { companion object { - private const val SECONDS_1_MINUTE: Long = 60 - private const val SECONDS_30_MINUTES = SECONDS_1_MINUTE * 30 - private const val SECONDS_1_HOUR = SECONDS_1_MINUTE * 60 - private const val SECONDS_3_HOURS = SECONDS_1_HOUR * 3 + private val THIRTY_MINUTES_IN_SECONDS = 30 * MINUTE_IN_SECONDS + private val THREE_HOURS_IN_SECONDS = 3 * HOUR_IN_SECONDS } fun formatMs(timeMs: Long): CharSequence { val seconds = roundConfirmationTimeInSeconds(timeMs / 1000) - val hours = seconds / SECONDS_1_HOUR - val minutes = (seconds % SECONDS_1_HOUR) / SECONDS_1_MINUTE + val hours = seconds / HOUR_IN_SECONDS + val minutes = (seconds % HOUR_IN_SECONDS) / MINUTE_IN_SECONDS - return if (seconds < SECONDS_1_HOUR) { + return if (seconds < HOUR_IN_SECONDS) { // Under an hour, just show "X minutes": context.getString(R.string.fee_option_item_mins, minutes) - } else if (seconds < SECONDS_3_HOURS) { + } else if (seconds < THREE_HOURS_IN_SECONDS) { // Under 3 hours, show "X hours Y minutes": context.getString(R.string.fee_option_item_hs_mins, hours, minutes) @@ -32,17 +32,17 @@ class ConfirmationTimeFormatter(val context: Context) { } private fun roundConfirmationTimeInSeconds(seconds: Long): Long { - return if (seconds <= SECONDS_30_MINUTES) { + return if (seconds <= THIRTY_MINUTES_IN_SECONDS) { // Never calculate less than 30 minutes: - SECONDS_30_MINUTES + THIRTY_MINUTES_IN_SECONDS - } else if (seconds < SECONDS_3_HOURS) { + } else if (seconds < THREE_HOURS_IN_SECONDS) { // Round up to the nearest 30-minute mark: - (seconds / SECONDS_30_MINUTES + 1) * SECONDS_30_MINUTES + (seconds / THIRTY_MINUTES_IN_SECONDS + 1) * THIRTY_MINUTES_IN_SECONDS } else { // Round up to the nearest hour mark: - (seconds / SECONDS_1_HOUR + 1) * SECONDS_1_HOUR + (seconds / HOUR_IN_SECONDS + 1) * HOUR_IN_SECONDS } } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/NewOperationInvoiceFormatter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/NewOperationInvoiceFormatter.kt new file mode 100644 index 00000000..8fd0804e --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/NewOperationInvoiceFormatter.kt @@ -0,0 +1,34 @@ +package io.muun.apollo.presentation.ui.utils + +import android.content.Context +import io.muun.apollo.R + +class NewOperationInvoiceFormatter(val context: Context) { + + companion object { + private const val MINUTE_IN_SECONDS: Long = 60 + private const val HOUR_IN_SECONDS = MINUTE_IN_SECONDS * 60 + private const val DAY_IN_SECONDS = HOUR_IN_SECONDS * 24 + private const val WEEK_IN_SECONDS = DAY_IN_SECONDS * 7 + } + + /** + * Format verbose wording, tailored for NewOperation screen. + */ + fun formatSeconds(timeInSeconds: Long): CharSequence { + + val weeks: Long = timeInSeconds / WEEK_IN_SECONDS + val days: Long = timeInSeconds % WEEK_IN_SECONDS / DAY_IN_SECONDS + val hours: Long = timeInSeconds % DAY_IN_SECONDS / HOUR_IN_SECONDS + val mins: Long = timeInSeconds % HOUR_IN_SECONDS / MINUTE_IN_SECONDS + val secs: Long = timeInSeconds % MINUTE_IN_SECONDS + + return when { + weeks > 0 -> context.getString(R.string.new_operation_invoice_exp_weeks, weeks, days) + days > 0 -> context.getString(R.string.new_operation_invoice_exp_days, days, hours) + hours > 0 -> context.getString(R.string.new_operation_invoice_exp_hours, hours, mins) + mins > 0 -> context.getString(R.string.new_operation_invoice_exp_minutes, mins, secs) + else -> context.getString(R.string.new_operation_invoice_exp_seconds, mins, secs) + } + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/PresenterProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/PresenterProvider.kt index 9fec3029..566aa5ed 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/PresenterProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/PresenterProvider.kt @@ -11,6 +11,7 @@ object PresenterProvider { idToPresenter[id] = presenter } + @Suppress("UNCHECKED_CAST") fun > get(id: String): T? { return idToPresenter[id] as? T } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ReceiveLnInvoiceFormatter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ReceiveLnInvoiceFormatter.kt new file mode 100644 index 00000000..3dc6bb64 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/ReceiveLnInvoiceFormatter.kt @@ -0,0 +1,20 @@ +package io.muun.apollo.presentation.ui.utils + +import io.muun.common.utils.Dates.HOUR_IN_SECONDS +import io.muun.common.utils.Dates.MINUTE_IN_SECONDS + +class ReceiveLnInvoiceFormatter { + + /** + * Format using minimalistic pattern/display/formatting instead of provided to better fit in + * small screen space. + */ + fun formatSeconds(timeInSeconds: Long): CharSequence { + + val hours = timeInSeconds / HOUR_IN_SECONDS + val minutes = (timeInSeconds % HOUR_IN_SECONDS) / MINUTE_IN_SECONDS + val seconds = timeInSeconds % MINUTE_IN_SECONDS + + return String.format("%d:%02d:%02d", hours, minutes, seconds) + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/BalanceView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/BalanceView.kt index 0f8499cb..f5f60782 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/BalanceView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/BalanceView.kt @@ -15,7 +15,7 @@ import io.muun.apollo.R import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.selector.UtxoSetStateSelector import io.muun.apollo.presentation.ui.bundler.CurrencyUnitBundler -import io.muun.apollo.presentation.ui.fragments.home.HomePresenter +import io.muun.apollo.presentation.ui.fragments.home.HomeFragmentPresenter import io.muun.apollo.presentation.ui.helper.BitcoinHelper import io.muun.apollo.presentation.ui.helper.MoneyHelper import io.muun.apollo.presentation.ui.helper.isBtc @@ -91,7 +91,7 @@ class BalanceView @JvmOverloads constructor( get() = R.layout.view_balance - fun setBalance(homeState: HomePresenter.HomeState) { + fun setBalance(homeState: HomeFragmentPresenter.HomeState) { val paymentContext = homeState.paymentContext this.rateProvider = ExchangeRateProvider(paymentContext.exchangeRateWindow.toJson()) 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 461cb4ee..bd061831 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 @@ -33,10 +33,10 @@ import javax.inject.Inject; import javax.money.MonetaryAmount; -public class FeeManualInput extends MuunView { +import static io.muun.common.utils.Dates.HOUR_IN_SECONDS; +import static io.muun.common.utils.Dates.MINUTE_IN_SECONDS; - private static final long MINUTE_IN_SECONDS = 60; - private static final long HOUR_IN_SECONDS = MINUTE_IN_SECONDS * 60; +public class FeeManualInput extends MuunView { public interface OnChangeListener { diff --git a/android/apolloui/src/main/res/layout/expiration_time_item.xml b/android/apolloui/src/main/res/layout/expiration_time_item.xml index 72a483d5..36a69a69 100644 --- a/android/apolloui/src/main/res/layout/expiration_time_item.xml +++ b/android/apolloui/src/main/res/layout/expiration_time_item.xml @@ -37,7 +37,7 @@ android:textColor="@color/text_secondary_color" android:fontFamily="sans-serif-medium" android:gravity="center" - tools:text="59:40"/> + tools:text="23:59:58"/> - Este pago requiere más tiempo restante antes de que expire la factura. Por favor, - crea o solicita una nueva factura. Sugerimos un tiempo de expiración de 1 hora o más. + Por favor solicita una nueva factura con un tiempo de expiración de al menos una hora, + o intenta escaneando una factura de menor monto. Esta factura ya fue utilizada @@ -142,8 +142,8 @@ Esta factura no incluye un monto - Por el momento, sólo se puede pagar a facturas que incluyan un monto. Por favor, - crea o solicita una nueva. + Por el momento, sólo se puede pagar a facturas que incluyan un monto. Por favor, crea o + solicita una nueva. No es posible pagar esta factura @@ -319,6 +319,7 @@ Pagaste a %s Te pagaron Pagaste + Pagaste a %s %s te pagó @@ -1115,10 +1116,15 @@ Crear factura Enviar a... - - Por razones de seguridad, tu sesión expiró. Por favor, ingresa nuevamente o contacta al - soporte técnico. + La unidad de almacenamiento seguro de tu teléfono se corrompió o fue comprometida. Por + razones de seguridad, tu sesión expiró. Por favor, escribinos a + support@muun.com para que podamos ayudarte. + + + La unidad de almacenamiento seguro de tu teléfono se corrompió o fue comprometida. Muun + no puede continuar ejecutando en estas condiciones. Por favor, escribinos a + support@muun.com para que podamos ayudarte. Unidad de bitcoin diff --git a/android/apolloui/src/main/res/values/strings.xml b/android/apolloui/src/main/res/values/strings.xml index 78a96343..82246966 100644 --- a/android/apolloui/src/main/res/values/strings.xml +++ b/android/apolloui/src/main/res/values/strings.xml @@ -127,8 +127,8 @@ This payment needs a longer expiration time - This payment requires more remaining time before the invoice expires. Please create or - request a new invoice. We suggest an expiration time of 1 hour or more. + Please request a new invoice with an expiration time of at least one hour, + or try scanning an invoice with a smaller amount. This invoice has expired @@ -310,6 +310,7 @@ You paid %s You were paid You paid + You paid %s %s paid you @@ -1083,6 +1084,11 @@ Muun has logged you out. Please contact support@muun.com for help. + + Your phone\'s secure storage has been corrupted or compromised. Muun can\'t continue working + in this conditions. Please contact + support@muun.com for help. + Bitcoin unit Bitcoin (BTC) diff --git a/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/utils/NewOperationInvoiceFormatterTest.kt b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/utils/NewOperationInvoiceFormatterTest.kt new file mode 100644 index 00000000..54891349 --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/utils/NewOperationInvoiceFormatterTest.kt @@ -0,0 +1,165 @@ +package io.muun.apollo.presentation.ui.utils + +import android.content.Context +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import io.muun.apollo.R +import io.muun.common.utils.Dates.DAY_IN_SECONDS +import io.muun.common.utils.Dates.HOUR_IN_SECONDS +import io.muun.common.utils.Dates.MINUTE_IN_SECONDS +import io.muun.common.utils.Dates.WEEK_IN_SECONDS +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.util.Locale + +class NewOperationInvoiceFormatterTest { + + private val captor0 = argumentCaptor() + private val captor1 = argumentCaptor() + private val captor2 = argumentCaptor() + private val captor3 = argumentCaptor() + private val captor4 = argumentCaptor() + private val captor5 = argumentCaptor() + private val captor6 = argumentCaptor() + private val captor7 = argumentCaptor() + private val captor8 = argumentCaptor() + private val captor9 = argumentCaptor() + + private val formatter = NewOperationInvoiceFormatter(buildContext(Locale.US)) + private val formatterES = NewOperationInvoiceFormatter(buildContext(Locale("es", "AR"))) + + private fun buildContext(locale: Locale): Context { + + val context = mock() + + doAnswer { + returnWeeksText(locale, captor0.firstValue, captor1.firstValue) + }.`when`(context) + .getString( + eq(R.string.new_operation_invoice_exp_weeks), + captor0.capture(), + captor1.capture() + ) + + doAnswer { + returnDaysText(locale, captor2.firstValue, captor3.firstValue) + }.`when`(context) + .getString( + eq(R.string.new_operation_invoice_exp_days), + captor2.capture(), + captor3.capture() + ) + + doAnswer { + returnHoursText(locale, captor4.firstValue, captor5.firstValue) + }.`when`(context) + .getString( + eq(R.string.new_operation_invoice_exp_hours), + captor4.capture(), + captor5.capture() + ) + + doAnswer { + returnMinutesText(captor6.firstValue, captor7.firstValue) + }.`when`(context) + .getString( + eq(R.string.new_operation_invoice_exp_minutes), + captor6.capture(), + captor7.capture() + ) + + doAnswer { + returnSecondsText(captor8.firstValue, captor9.firstValue) + }.`when`(context) + .getString( + eq(R.string.new_operation_invoice_exp_seconds), + captor8.capture(), + captor9.capture() + ) + + return context + } + + private fun returnWeeksText(locale: Locale, weeks: Long, days: Long): String = + if (locale == Locale.US) { + "$weeks weeks and $days days" + } else { + "$weeks semanas y $days dias" + } + + private fun returnDaysText(locale: Locale, days: Long, hours: Long): String = + if (locale == Locale.US) { + "$days days and $hours hours" + } else { + "$days dias y $hours horas" + } + + private fun returnHoursText(locale: Locale, hours: Long, minutes: Long): String = + if (locale == Locale.US) { + "$hours hours and $minutes minutes" + } else { + "$hours horas y $minutes minutos" + } + + private fun returnMinutesText(minutes: Long, seconds: Long): String = + String.format("%02d:%02d", minutes, seconds) + + private fun returnSecondsText(minutes: Long, seconds: Long): String = + String.format("%02d:%02d", minutes, seconds) + + @Test + fun `test duration in weeks`() { + + val timeInSeconds = 1 * WEEK_IN_SECONDS + + 3 * DAY_IN_SECONDS + + 2 * HOUR_IN_SECONDS + + 40 * MINUTE_IN_SECONDS + + 24 + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("1 weeks and 3 days") + assertThat(formatterES.formatSeconds(timeInSeconds)).isEqualTo("1 semanas y 3 dias") + } + + @Test + fun `test duration in days`() { + + val timeInSeconds = 3 * DAY_IN_SECONDS + + 2 * HOUR_IN_SECONDS + + 40 * MINUTE_IN_SECONDS + + 24 + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("3 days and 2 hours") + assertThat(formatterES.formatSeconds(timeInSeconds)).isEqualTo("3 dias y 2 horas") + } + + @Test + fun `test duration in hours`() { + + val timeInSeconds = 2 * HOUR_IN_SECONDS + + 40 * MINUTE_IN_SECONDS + + 24 + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("2 hours and 40 minutes") + assertThat(formatterES.formatSeconds(timeInSeconds)).isEqualTo("2 horas y 40 minutos") + } + + @Test + fun `test duration in minutes`() { + + val timeInSeconds = 40 * MINUTE_IN_SECONDS + 24 + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("40:24") + assertThat(formatterES.formatSeconds(timeInSeconds)).isEqualTo("40:24") + } + + @Test + fun `test duration in seconds`() { + + val timeInSeconds = 24L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("00:24") + assertThat(formatterES.formatSeconds(timeInSeconds)).isEqualTo("00:24") + } +} \ No newline at end of file diff --git a/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/utils/ReceiveLnInvoiceFormatterTest.kt b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/utils/ReceiveLnInvoiceFormatterTest.kt new file mode 100644 index 00000000..825dc3a8 --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/utils/ReceiveLnInvoiceFormatterTest.kt @@ -0,0 +1,51 @@ +package io.muun.apollo.presentation.ui.utils + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ReceiveLnInvoiceFormatterTest { + + private val formatter = ReceiveLnInvoiceFormatter() + + @Test + fun `formats duration over 24hs`() { + val timeInSeconds = 30 * 3600 + 60 * 43 + 24L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("30:43:24") + } + + @Test + fun `formats duration with exact hours`() { + val timeInSeconds = 24 * 3600L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("24:00:00") + } + + @Test + fun `formats duration under 24hs`() { + val timeInSeconds = 19 * 3600 + 60 * 43 + 24L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("19:43:24") + } + + @Test + fun `formats duration under 10hs`() { + val timeInSeconds = 9 * 3600 + 60 * 43 + 24L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("9:43:24") + } + + @Test + fun `formats duration under 1h`() { + val timeInSeconds = 60 * 43 + 24L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("0:43:24") + } + + @Test + fun `formats duration under 10 min`() { + val timeInSeconds = 60 * 7 + 24L + + assertThat(formatter.formatSeconds(timeInSeconds)).isEqualTo("0:07:24") + } +} \ No newline at end of file diff --git a/common/src/main/java/io/muun/common/Supports.java b/common/src/main/java/io/muun/common/Supports.java index bd5494f8..55874fb2 100644 --- a/common/src/main/java/io/muun/common/Supports.java +++ b/common/src/main/java/io/muun/common/Supports.java @@ -122,4 +122,8 @@ public interface PaginatedNotifications { int APOLLO = 900; int FALCON = 706; } + + public interface BrokenTransactionHashProcessing { + int APOLLO = 901; + } } diff --git a/common/src/main/java/io/muun/common/crypto/CryptographyException.java b/common/src/main/java/io/muun/common/crypto/CryptographyException.java index 7b04cdac..d7dba176 100644 --- a/common/src/main/java/io/muun/common/crypto/CryptographyException.java +++ b/common/src/main/java/io/muun/common/crypto/CryptographyException.java @@ -1,6 +1,7 @@ package io.muun.common.crypto; public class CryptographyException extends RuntimeException { + public CryptographyException(String message) { super(message); } diff --git a/common/src/main/java/io/muun/common/utils/Dates.java b/common/src/main/java/io/muun/common/utils/Dates.java index 0f8ee068..888c98b9 100644 --- a/common/src/main/java/io/muun/common/utils/Dates.java +++ b/common/src/main/java/io/muun/common/utils/Dates.java @@ -4,10 +4,16 @@ public final class Dates { + public static final DateTimeFormatter ISO_DATE_TIME_WITH_MILLIS = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); + // Ideally, we would like to use DateTimeFormatter.ISO_DATE_TIME but it doesn't handle millis. // This does the exact same thing, plus handling of milliseconds. // More info on: https://bit.ly/2RP3EAt - public static final DateTimeFormatter LN_DATE_TIME_FORMATTER = DateTimeFormatter - .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); + public static final DateTimeFormatter LN_DATE_TIME = ISO_DATE_TIME_WITH_MILLIS; + public static final Long MINUTE_IN_SECONDS = 60L; + public static final Long HOUR_IN_SECONDS = MINUTE_IN_SECONDS * 60; + public static final Long DAY_IN_SECONDS = HOUR_IN_SECONDS * 24; + public static final Long WEEK_IN_SECONDS = DAY_IN_SECONDS * 7; } diff --git a/common/src/main/java/io/muun/common/utils/LnInvoice.java b/common/src/main/java/io/muun/common/utils/LnInvoice.java index 2547e5ca..8492c672 100644 --- a/common/src/main/java/io/muun/common/utils/LnInvoice.java +++ b/common/src/main/java/io/muun/common/utils/LnInvoice.java @@ -121,18 +121,18 @@ private LnInvoice(String original, this.original = original; this.addresses = addresses; this.cltvDelta = cltvDelta; - this.createdAt = createdAt.format(Dates.LN_DATE_TIME_FORMATTER); + this.createdAt = createdAt.format(Dates.LN_DATE_TIME); this.description = description; this.descriptionHash = descriptionHash; this.destinationPubKey = destinationPubKey; - this.expiresAt = expiresAt.format(Dates.LN_DATE_TIME_FORMATTER); + this.expiresAt = expiresAt.format(Dates.LN_DATE_TIME); this.id = id; this.isExpired = expiresAt.compareTo(ZonedDateTime.now(ZoneOffset.UTC)) < 0; this.amount = amount; } public ZonedDateTime getExpirationTime() { - return ZonedDateTime.parse(expiresAt, Dates.LN_DATE_TIME_FORMATTER); + return ZonedDateTime.parse(expiresAt, Dates.LN_DATE_TIME); } /** diff --git a/libwallet/newop/context_test.go b/libwallet/newop/context_test.go new file mode 100644 index 00000000..6e2eb7c6 --- /dev/null +++ b/libwallet/newop/context_test.go @@ -0,0 +1,65 @@ +package newop + +import ( + "github.com/shopspring/decimal" + "testing" +) + +var testPaymentContext = createTestPaymentContext() + +func createTestPaymentContext() *PaymentContext { + var context = &PaymentContext{ + NextTransactionSize: &NextTransactionSize{ + ExpectedDebtInSat: 10_000, + }, + ExchangeRateWindow: &ExchangeRateWindow{ + rates: make(map[string]float64), + }, + FeeWindow: &FeeWindow{}, + PrimaryCurrency: "BTC", + MinFeeRateInSatsPerVByte: 1.0, + } + context.NextTransactionSize.AddSizeForAmount(&SizeForAmount{ + AmountInSat: 100_000_000, + SizeInVByte: 240, + }) + + context.ExchangeRateWindow.AddRate("BTC", 1) + context.ExchangeRateWindow.AddRate("USD", 32_000) + + return context +} + +func TestPaymentContextTotalBalance(t *testing.T) { + + totalBalance := testPaymentContext.totalBalance() + + if totalBalance != 99_990_000 { + t.Fatalf("expected totalBalance to be 90_000, got %v", totalBalance) + } +} + +func TestPaymentContextToBitcoinAmount(t *testing.T) { + + btcAmount := testPaymentContext.toBitcoinAmount(100_000, "USD") + + if btcAmount.InSat != 100_000 { + t.Fatalf("expected bitcoin amount in sats to remain unchanged and be 100_000, got %v", btcAmount.InSat) + } + + if btcAmount.InInputCurrency.Currency != "USD" { + t.Fatalf("expected bitcoin amount input currency to be USD, got %v", btcAmount.InInputCurrency.Currency) + } + + if btcAmount.InInputCurrency.Value.Cmp(decimal.NewFromInt(32)) != 0 { + t.Fatalf("expected converted amount to be 32, got %v", btcAmount.InInputCurrency.Value) + } + + if btcAmount.InPrimaryCurrency.Currency != "BTC" { + t.Fatalf("expected bitcoin amount primary currency to be BTC, got %v", btcAmount.InPrimaryCurrency.Currency) + } + + if btcAmount.InPrimaryCurrency.Value.Cmp(decimal.NewFromFloat(0.001)) != 0 { + t.Fatalf("expected amount in primary currency to be 0.001, got %v", btcAmount.InPrimaryCurrency.Value) + } +} diff --git a/libwallet/newop/money_test.go b/libwallet/newop/money_test.go index c553a60e..4f316e0e 100644 --- a/libwallet/newop/money_test.go +++ b/libwallet/newop/money_test.go @@ -40,6 +40,14 @@ func TestMonetaryAmountToBitcoinAmount(t *testing.T) { t.Fatalf("expected converted amount to be 32000, got %v", bitcoinAmount.InInputCurrency.Value) } + if bitcoinAmount.InInputCurrency.Currency != "BTC" { + t.Fatalf("expected intput currency to be BTC, got %v", bitcoinAmount.InInputCurrency.Currency) + } + + if bitcoinAmount.InInputCurrency.Value.Cmp(decimal.NewFromInt(1)) != 0 { + t.Fatalf("expected converted amount to be 1, got %v", bitcoinAmount.InInputCurrency.Value) + } + } func TestMonetaryAmountAdd(t *testing.T) { diff --git a/libwallet/newop/state.go b/libwallet/newop/state.go index 939c78db..5e072d0f 100644 --- a/libwallet/newop/state.go +++ b/libwallet/newop/state.go @@ -974,6 +974,8 @@ func (s *EditFeeState) MinFeeRateForTarget(target int) (float64, error) { return feeWindow.MinimumFeeRate(uint(target)) } +// TODO this currently ignores and forgets input currency, which is important for amount display +// logic in Edit Fee screens func (s *EditFeeState) CalculateFee(rateInSatsPerVByte float64) (*FeeState, error) { amountInSat := s.Amount.InSat if s.TakeFeeFromAmount { diff --git a/libwallet/newop/state_test.go b/libwallet/newop/state_test.go index f3ec18b5..d2f5c58b 100644 --- a/libwallet/newop/state_test.go +++ b/libwallet/newop/state_test.go @@ -63,6 +63,7 @@ func createContext() *PaymentContext { return context } +//goland:noinspection GoUnhandledErrorResult func TestBarebonesOnChainFixedAmountFixedFee(t *testing.T) { listener := newTestListener() @@ -96,6 +97,7 @@ func TestBarebonesOnChainFixedAmountFixedFee(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestBarebonesOnChainFixedAmountFixedDescriptionFixedFee(t *testing.T) { listener := newTestListener() @@ -126,6 +128,7 @@ func TestBarebonesOnChainFixedAmountFixedDescriptionFixedFee(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainFixedAmountChangeFee(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) @@ -185,6 +188,8 @@ func TestOnChainFixedAmountChangeFee(t *testing.T) { t.Fatalf("expected total to match, got %v", confirmState.Total.InInputCurrency) } } + +//goland:noinspection GoUnhandledErrorResult func TestOnChainFixedAmountFeeNeedsChange(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) @@ -253,6 +258,7 @@ func TestOnChainFixedAmountFeeNeedsChange(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainFixedAmountNoPossibleFee(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) @@ -277,6 +283,7 @@ func TestOnChainFixedAmountNoPossibleFee(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainFixedAmountTooSmall(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) @@ -293,6 +300,7 @@ func TestOnChainFixedAmountTooSmall(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainFixedAmountGreaterThanbalance(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) @@ -317,6 +325,7 @@ func TestOnChainFixedAmountGreaterThanbalance(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainSendZeroFundsWithZeroBalance(t *testing.T) { listener := newTestListener() @@ -343,6 +352,7 @@ func TestOnChainSendZeroFundsWithZeroBalance(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainTFFA(t *testing.T) { listener := newTestListener() @@ -392,6 +402,7 @@ func TestOnChainTFFA(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestInvalidAmountEmitsInvalidAddress(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) @@ -409,6 +420,7 @@ func TestInvalidAmountEmitsInvalidAddress(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainBack(t *testing.T) { listener := newTestListener() @@ -435,12 +447,40 @@ func TestOnChainBack(t *testing.T) { enterDescriptionState.Back() enterAmountState = listener.next().(*EnterAmountState) + // TODO when deleting this method impl (deprecated) rm lines below up until the call to ChangeCurrencyWithAmount enterAmountState.ChangeCurrency("USD") enterAmountState = listener.next().(*EnterAmountState) enterAmountState.Back() abortState := listener.next().(*AbortState) + if abortState.update != UpdateAll { + t.Fatalf("expected normal/full update , got %v", abortState.update) + } + abortState.Cancel() + + enterAmountState = listener.next().(*EnterAmountState) + if enterAmountState.update != UpdateEmpty { + t.Fatalf("expected empty update, got %v", enterAmountState.update) + } + enterAmountState.Back() + + abortState = listener.next().(*AbortState) + if abortState.update != UpdateAll { + t.Fatalf("expected normal/full update , got %v", abortState.update) + } + abortState.Cancel() + + enterAmountState = listener.next().(*EnterAmountState) + enterAmountState.ChangeCurrencyWithAmount("USD", NewMonetaryAmountFromSatoshis(1_000_000)) + + enterAmountState = listener.next().(*EnterAmountState) + enterAmountState.Back() + + abortState = listener.next().(*AbortState) + if abortState.update != UpdateAll { + t.Fatalf("expected normal/full update , got %v", abortState.update) + } abortState.Cancel() enterAmountState = listener.next().(*EnterAmountState) @@ -462,6 +502,7 @@ func TestOnChainBack(t *testing.T) { _ = listener.next().(*AbortState) } +//goland:noinspection GoUnhandledErrorResult func TestOnChainChangeCurrency(t *testing.T) { listener := newTestListener() @@ -491,6 +532,7 @@ func TestOnChainChangeCurrency(t *testing.T) { enterDescriptionState.Back() enterAmountState = listener.next().(*EnterAmountState) + // TODO when deleting this method impl (deprecated) rm lines below up until the call to ChangeCurrencyWithAmount enterAmountState.ChangeCurrency("USD") enterAmountState = listener.next().(*EnterAmountState) @@ -503,6 +545,9 @@ func TestOnChainChangeCurrency(t *testing.T) { if enterAmountState.Amount.InInputCurrency.String() != "32000 USD" { t.Fatalf("expected amount to match 32000 USD, got '%v'", enterAmountState.Amount.InInputCurrency.String()) } + if enterAmountState.Amount.InPrimaryCurrency.String() != "1 BTC" { + t.Fatalf("expected amount to match 1 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + } enterAmountState.ChangeCurrency("BTC") enterAmountState = listener.next().(*EnterAmountState) @@ -515,6 +560,39 @@ func TestOnChainChangeCurrency(t *testing.T) { if enterAmountState.Amount.InInputCurrency.String() != "1 BTC" { t.Fatalf("expected amount to match 1 BTC, got '%v'", enterAmountState.Amount.InInputCurrency.String()) } + if enterAmountState.Amount.InPrimaryCurrency.String() != "1 BTC" { + t.Fatalf("expected amount to match 1 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + } + + enterAmountState.ChangeCurrencyWithAmount("USD", NewMonetaryAmountFromSatoshis(1_000_000)) + enterAmountState = listener.next().(*EnterAmountState) + if enterAmountState.update != UpdateInPlace { + t.Fatalf("expected UpdateInPlace, got '%v'", enterAmountState.update) + } + if enterAmountState.Amount.InSat != 1_000_000 { + t.Fatalf("expected amount to match 1_000_000, got '%v'", enterAmountState.Amount.InSat) + } + if enterAmountState.Amount.InInputCurrency.String() != "320 USD" { + t.Fatalf("expected amount to match 320 USD, got '%v'", enterAmountState.Amount.InInputCurrency.String()) + } + if enterAmountState.Amount.InPrimaryCurrency.String() != "0.01 BTC" { + t.Fatalf("expected amount to match 0.01 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + } + + enterAmountState.ChangeCurrencyWithAmount("BTC", enterAmountState.Amount.InInputCurrency) + enterAmountState = listener.next().(*EnterAmountState) + if enterAmountState.update != UpdateInPlace { + t.Fatalf("expected UpdateInPlace, got '%v'", enterAmountState.update) + } + if enterAmountState.Amount.InSat != 1_000_000 { + t.Fatalf("expected amount to match 1_000_000, got '%v'", enterAmountState.Amount.InSat) + } + if enterAmountState.Amount.InInputCurrency.String() != "0.01 BTC" { + t.Fatalf("expected amount to match 0.01 BTC, got '%v'", enterAmountState.Amount.InInputCurrency.String()) + } + if enterAmountState.Amount.InPrimaryCurrency.String() != "0.01 BTC" { + t.Fatalf("expected amount to match 0.01 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + } enterDescriptionState.EnterDescription("bar") confirmState := listener.next().(*ConfirmState) @@ -544,6 +622,7 @@ func TestOnChainChangeCurrency(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningSendZeroFunds(t *testing.T) { listener := newTestListener() @@ -590,6 +669,7 @@ func TestLightningSendZeroFunds(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningSendZeroFundsTFFA(t *testing.T) { listener := newTestListener() @@ -636,6 +716,7 @@ func TestLightningSendZeroFundsTFFA(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningSendNegativeFunds(t *testing.T) { listener := newTestListener() @@ -682,6 +763,7 @@ func TestLightningSendNegativeFunds(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningSendNegativeFundsWithTFFA(t *testing.T) { listener := newTestListener() @@ -728,6 +810,7 @@ func TestLightningSendNegativeFundsWithTFFA(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningExpiredInvoice(t *testing.T) { listener := newTestListener() @@ -750,6 +833,7 @@ func TestLightningExpiredInvoice(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningInvoiceWithAmount(t *testing.T) { listener := newTestListener() @@ -800,6 +884,7 @@ func TestLightningInvoiceWithAmount(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningWithAmountBack(t *testing.T) { listener := newTestListener() @@ -859,6 +944,7 @@ func TestLightningWithAmountBack(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningInvoiceWithAmountAndDescription(t *testing.T) { listener := newTestListener() @@ -906,6 +992,7 @@ func TestLightningInvoiceWithAmountAndDescription(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestLightningAmountlessInvoice(t *testing.T) { listener := newTestListener() @@ -973,6 +1060,7 @@ func TestLightningAmountlessInvoice(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestInvoiceOneConf(t *testing.T) { listener := newTestListener() @@ -1036,6 +1124,7 @@ func TestInvoiceOneConf(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestAmountConversion(t *testing.T) { // This test repros a bug where we had: @@ -1098,6 +1187,7 @@ func TestAmountConversion(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestInvoiceUnpayable(t *testing.T) { listener := newTestListener() @@ -1149,6 +1239,7 @@ func TestInvoiceUnpayable(t *testing.T) { } +//goland:noinspection GoUnhandledErrorResult func TestInvoiceLend(t *testing.T) { listener := newTestListener() @@ -1205,6 +1296,7 @@ func TestInvoiceLend(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestAmountInfo_Mutating(t *testing.T) { amountInfo := &AmountInfo{ TakeFeeFromAmount: false, @@ -1223,6 +1315,7 @@ func TestAmountInfo_Mutating(t *testing.T) { } } +//goland:noinspection GoUnhandledErrorResult func TestOnChainTFFAWithDebtFeeNeedsChangeBecauseOutputAmountLowerThanDust(t *testing.T) { listener := newTestListener()