[#1108] Ktlint 1.0.1

* [#1108] Ktlint 1.0.1

- Closes #1108
- Version and artefact update

* Fix ktlint warnings
This commit is contained in:
Honza Rychnovský 2023-12-11 10:20:32 +01:00 committed by GitHub
parent a10b372e73
commit a3e7d8f6c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
257 changed files with 4229 additions and 3516 deletions

View File

@ -10,7 +10,6 @@ import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
class AndroidApiTest { class AndroidApiTest {
@Test @Test
@SmallTest @SmallTest
fun checkTargetApi() { fun checkTargetApi() {

View File

@ -9,7 +9,6 @@ import kotlinx.coroutines.launch
@Suppress("unused") @Suppress("unused")
class ZcashApplication : CoroutineApplication() { class ZcashApplication : CoroutineApplication() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View File

@ -5,7 +5,7 @@ plugins {
val ktlint by configurations.creating val ktlint by configurations.creating
dependencies { dependencies {
ktlint("com.pinterest:ktlint:${project.property("KTLINT_VERSION")}") { ktlint("com.pinterest.ktlint:ktlint-cli:${project.property("KTLINT_VERSION")}") {
attributes { attributes {
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named<Bundling>(Bundling.EXTERNAL)) attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named<Bundling>(Bundling.EXTERNAL))
} }

View File

@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.Flow
* Provides a remote config implementation. * Provides a remote config implementation.
*/ */
interface ConfigurationProvider { interface ConfigurationProvider {
/** /**
* @return The configuration if it has been loaded already. If not loaded, returns an empty configuration. * @return The configuration if it has been loaded already. If not loaded, returns an empty configuration.
*/ */

View File

@ -40,19 +40,28 @@ private data class MergingConfiguration(private val configurations: PersistentLi
return null != configurations.firstWithKey(key) return null != configurations.firstWithKey(key)
} }
override fun getBoolean(key: ConfigKey, defaultValue: Boolean): Boolean { override fun getBoolean(
key: ConfigKey,
defaultValue: Boolean
): Boolean {
return configurations.firstWithKey(key)?.let { return configurations.firstWithKey(key)?.let {
return it.getBoolean(key, defaultValue) return it.getBoolean(key, defaultValue)
} ?: defaultValue } ?: defaultValue
} }
override fun getInt(key: ConfigKey, defaultValue: Int): Int { override fun getInt(
key: ConfigKey,
defaultValue: Int
): Int {
return configurations.firstWithKey(key)?.let { return configurations.firstWithKey(key)?.let {
return it.getInt(key, defaultValue) return it.getInt(key, defaultValue)
} ?: defaultValue } ?: defaultValue
} }
override fun getString(key: ConfigKey, defaultValue: String): String { override fun getString(
key: ConfigKey,
defaultValue: String
): String {
return configurations.firstWithKey(key)?.let { return configurations.firstWithKey(key)?.let {
return it.getString(key, defaultValue) return it.getString(key, defaultValue)
} ?: defaultValue } ?: defaultValue

View File

@ -6,7 +6,5 @@ data class BooleanConfigurationEntry(
override val key: ConfigKey, override val key: ConfigKey,
private val defaultValue: Boolean private val defaultValue: Boolean
) : DefaultEntry<Boolean> { ) : DefaultEntry<Boolean> {
override fun getValue(configuration: Configuration) = configuration.getBoolean(key, defaultValue)
override fun getValue(configuration: Configuration) =
configuration.getBoolean(key, defaultValue)
} }

View File

@ -8,16 +8,16 @@ import co.electriccoin.zcash.configuration.model.map.Configuration
* variation in default value. Clients define the key and default value together, rather than just * variation in default value. Clients define the key and default value together, rather than just
* the key. * the key.
*/ */
/*
* API note: the default value is not available through the public interface in order to prevent
* clients from accidentally using the default value instead of the configuration value.
*
* Implementation note: although primitives would be nice, Objects don't increase memory usage much.
* The autoboxing cache solves Booleans, and Strings are already objects, so that just leaves Integers.
* Overall the number of Integer configuration entries is expected to be low compared to Booleans,
* and perhaps many Integer values will also fit within the autoboxing cache.
*/
interface DefaultEntry<T> { interface DefaultEntry<T> {
/*
* API note: the default value is not available through the public interface in order to prevent
* clients from accidentally using the default value instead of the configuration value.
*
* Implementation note: although primitives would be nice, Objects don't increase memory usage much.
* The autoboxing cache solves Booleans, and Strings are already objects, so that just leaves Integers.
* Overall the number of Integer configuration entries is expected to be low compared to Booleans,
* and perhaps many Integer values will also fit within the autoboxing cache.
*/
val key: ConfigKey val key: ConfigKey

View File

@ -6,6 +6,5 @@ data class IntegerConfigurationEntry(
override val key: ConfigKey, override val key: ConfigKey,
private val defaultValue: Int private val defaultValue: Int
) : DefaultEntry<Int> { ) : DefaultEntry<Int> {
override fun getValue(configuration: Configuration) = configuration.getInt(key, defaultValue) override fun getValue(configuration: Configuration) = configuration.getInt(key, defaultValue)
} }

View File

@ -6,6 +6,5 @@ data class StringConfigurationEntry(
override val key: ConfigKey, override val key: ConfigKey,
private val defaultValue: String private val defaultValue: String
) : DefaultEntry<String> { ) : DefaultEntry<String> {
override fun getValue(configuration: Configuration) = configuration.getString(key, defaultValue) override fun getValue(configuration: Configuration) = configuration.getString(key, defaultValue)
} }

View File

@ -26,7 +26,10 @@ interface Configuration {
* be returned if type coercion fails. * be returned if type coercion fails.
* @return boolean mapping for `key` or `defaultValue`. * @return boolean mapping for `key` or `defaultValue`.
*/ */
fun getBoolean(key: ConfigKey, defaultValue: Boolean): Boolean fun getBoolean(
key: ConfigKey,
defaultValue: Boolean
): Boolean
/** /**
* @param key Key to use to retrieve the value. * @param key Key to use to retrieve the value.
@ -35,7 +38,10 @@ interface Configuration {
* be returned if type coercion fails. * be returned if type coercion fails.
* @return int mapping for `key` or `defaultValue`. * @return int mapping for `key` or `defaultValue`.
*/ */
fun getInt(key: ConfigKey, defaultValue: Int): Int fun getInt(
key: ConfigKey,
defaultValue: Int
): Int
/** /**
* @param key Key to use to retrieve the value. * @param key Key to use to retrieve the value.
@ -44,5 +50,8 @@ interface Configuration {
* be returned if type coercion fails. * be returned if type coercion fails.
* @return String mapping for `key` or `defaultValue`. * @return String mapping for `key` or `defaultValue`.
*/ */
fun getString(key: ConfigKey, defaultValue: String): String fun getString(
key: ConfigKey,
defaultValue: String
): String
} }

View File

@ -10,30 +10,38 @@ data class StringConfiguration(
val configurationMapping: PersistentMap<String, String>, val configurationMapping: PersistentMap<String, String>,
override val updatedAt: Instant? override val updatedAt: Instant?
) : Configuration { ) : Configuration {
override fun getBoolean( override fun getBoolean(
key: ConfigKey, key: ConfigKey,
defaultValue: Boolean defaultValue: Boolean
) = configurationMapping[key.key]?.let { ) = configurationMapping[key.key]?.let {
try { try {
it.toBooleanStrict() it.toBooleanStrict()
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { } catch (
@Suppress("SwallowedException") e: IllegalArgumentException
) {
// In the future, log coercion failure as this could mean someone made an error in the remote config console // In the future, log coercion failure as this could mean someone made an error in the remote config console
defaultValue defaultValue
} }
} ?: defaultValue } ?: defaultValue
override fun getInt(key: ConfigKey, defaultValue: Int) = configurationMapping[key.key]?.let { override fun getInt(
key: ConfigKey,
defaultValue: Int
) = configurationMapping[key.key]?.let {
try { try {
it.toInt() it.toInt()
} catch (@Suppress("SwallowedException") e: NumberFormatException) { } catch (
@Suppress("SwallowedException") e: NumberFormatException
) {
// In the future, log coercion failure as this could mean someone made an error in the remote config console // In the future, log coercion failure as this could mean someone made an error in the remote config console
defaultValue defaultValue
} }
} ?: defaultValue } ?: defaultValue
override fun getString(key: ConfigKey, defaultValue: String) = override fun getString(
configurationMapping.getOrElse(key.key) { defaultValue } key: ConfigKey,
defaultValue: String
) = configurationMapping.getOrElse(key.key) { defaultValue }
override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key) override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key)
} }

View File

@ -18,72 +18,98 @@ import kotlin.test.assertTrue
class MergingConfigurationProviderTest { class MergingConfigurationProviderTest {
@Test @Test
fun peek_ordering() { fun peek_ordering() {
val configurationProvider = MergingConfigurationProvider( val configurationProvider =
persistentListOf( MergingConfigurationProvider(
MockConfigurationProvider( persistentListOf(
StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()), null) MockConfigurationProvider(
), StringConfiguration(
MockConfigurationProvider( persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()),
StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()), null) null
)
),
MockConfigurationProvider(
StringConfiguration(
persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()),
null
)
)
) )
) )
)
assertTrue(BooleanDefaultEntryFixture.newTrueEntry().getValue(configurationProvider.peekConfiguration())) assertTrue(BooleanDefaultEntryFixture.newTrueEntry().getValue(configurationProvider.peekConfiguration()))
} }
@Test @Test
fun getFlow_ordering() = runTest { fun getFlow_ordering() =
val configurationProvider = MergingConfigurationProvider( runTest {
persistentListOf( val configurationProvider =
MockConfigurationProvider( MergingConfigurationProvider(
StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()), null) persistentListOf(
), MockConfigurationProvider(
MockConfigurationProvider( StringConfiguration(
StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()), null) persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()),
null
)
),
MockConfigurationProvider(
StringConfiguration(
persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()),
null
)
)
)
) )
)
)
assertTrue( assertTrue(
BooleanDefaultEntryFixture.newTrueEntry().getValue(configurationProvider.getConfigurationFlow().first()) BooleanDefaultEntryFixture.newTrueEntry().getValue(configurationProvider.getConfigurationFlow().first())
) )
} }
@Test @Test
fun getFlow_empty() = runTest { fun getFlow_empty() =
val configurationProvider = MergingConfigurationProvider( runTest {
emptyList<ConfigurationProvider>().toPersistentList() val configurationProvider =
) MergingConfigurationProvider(
emptyList<ConfigurationProvider>().toPersistentList()
)
val firstMergedConfiguration = configurationProvider.getConfigurationFlow().first() val firstMergedConfiguration = configurationProvider.getConfigurationFlow().first()
assertTrue(BooleanDefaultEntryFixture.newTrueEntry().getValue(firstMergedConfiguration)) assertTrue(BooleanDefaultEntryFixture.newTrueEntry().getValue(firstMergedConfiguration))
} }
@Test @Test
fun getUpdatedAt_newest() = runTest { fun getUpdatedAt_newest() =
val older = "2023-01-15T08:38:45.415Z".toInstant() runTest {
val newer = "2023-01-17T08:38:45.415Z".toInstant() val older = "2023-01-15T08:38:45.415Z".toInstant()
val newer = "2023-01-17T08:38:45.415Z".toInstant()
val configurationProvider = MergingConfigurationProvider( val configurationProvider =
persistentListOf( MergingConfigurationProvider(
MockConfigurationProvider( persistentListOf(
StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()), older) MockConfigurationProvider(
), StringConfiguration(
MockConfigurationProvider( persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()),
StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()), newer) older
)
),
MockConfigurationProvider(
StringConfiguration(
persistentMapOf(
BooleanDefaultEntryFixture.KEY.key to false.toString()
),
newer
)
)
)
) )
)
)
val updatedAt = configurationProvider.getConfigurationFlow().first().updatedAt val updatedAt = configurationProvider.getConfigurationFlow().first().updatedAt
assertEquals(newer, updatedAt) assertEquals(newer, updatedAt)
} }
} }
private class MockConfigurationProvider(private val configuration: Configuration) : ConfigurationProvider { private class MockConfigurationProvider(private val configuration: Configuration) : ConfigurationProvider {
override fun peekConfiguration(): Configuration { override fun peekConfiguration(): Configuration {
return configuration return configuration
} }

View File

@ -11,7 +11,6 @@ import kotlinx.datetime.Instant
* though, making the initial mapping thread-safe. * though, making the initial mapping thread-safe.
*/ */
class MockConfiguration(private val configurationMapping: Map<String, String> = emptyMap()) : Configuration { class MockConfiguration(private val configurationMapping: Map<String, String> = emptyMap()) : Configuration {
override val updatedAt: Instant? = null override val updatedAt: Instant? = null
override fun getBoolean( override fun getBoolean(
@ -20,23 +19,32 @@ class MockConfiguration(private val configurationMapping: Map<String, String> =
) = configurationMapping[key.key]?.let { ) = configurationMapping[key.key]?.let {
try { try {
it.toBooleanStrict() it.toBooleanStrict()
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { } catch (
@Suppress("SwallowedException") e: IllegalArgumentException
) {
// In the future, log coercion failure as this could mean someone made an error in the remote config console // In the future, log coercion failure as this could mean someone made an error in the remote config console
defaultValue defaultValue
} }
} ?: defaultValue } ?: defaultValue
override fun getInt(key: ConfigKey, defaultValue: Int) = configurationMapping[key.key]?.let { override fun getInt(
key: ConfigKey,
defaultValue: Int
) = configurationMapping[key.key]?.let {
try { try {
it.toInt() it.toInt()
} catch (@Suppress("SwallowedException") e: NumberFormatException) { } catch (
@Suppress("SwallowedException") e: NumberFormatException
) {
// In the future, log coercion failure as this could mean someone made an error in the remote config console // In the future, log coercion failure as this could mean someone made an error in the remote config console
defaultValue defaultValue
} }
} ?: defaultValue } ?: defaultValue
override fun getString(key: ConfigKey, defaultValue: String) = override fun getString(
configurationMapping.getOrElse(key.key) { defaultValue } key: ConfigKey,
defaultValue: String
) = configurationMapping.getOrElse(key.key) { defaultValue }
override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key) override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key)
} }

View File

@ -4,7 +4,6 @@ import co.electriccoin.zcash.configuration.model.entry.BooleanConfigurationEntry
import co.electriccoin.zcash.configuration.model.entry.ConfigKey import co.electriccoin.zcash.configuration.model.entry.ConfigKey
object BooleanDefaultEntryFixture { object BooleanDefaultEntryFixture {
val KEY = ConfigKey("some_boolean_key") // $NON-NLS val KEY = ConfigKey("some_boolean_key") // $NON-NLS
fun newTrueEntry() = BooleanConfigurationEntry(KEY, true) fun newTrueEntry() = BooleanConfigurationEntry(KEY, true)

View File

@ -6,5 +6,9 @@ import co.electriccoin.zcash.configuration.model.entry.IntegerConfigurationEntry
object IntegerDefaultEntryFixture { object IntegerDefaultEntryFixture {
val KEY = ConfigKey("some_string_key") // $NON-NLS val KEY = ConfigKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = 123 const val DEFAULT_VALUE = 123
fun newEntry(key: ConfigKey = KEY, value: Int = DEFAULT_VALUE) = IntegerConfigurationEntry(key, value)
fun newEntry(
key: ConfigKey = KEY,
value: Int = DEFAULT_VALUE
) = IntegerConfigurationEntry(key, value)
} }

View File

@ -6,5 +6,9 @@ import co.electriccoin.zcash.configuration.model.entry.StringConfigurationEntry
object StringDefaultEntryFixture { object StringDefaultEntryFixture {
val KEY = ConfigKey("some_string_key") // $NON-NLS val KEY = ConfigKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun newEntryEntry(key: ConfigKey = KEY, value: String = DEFAULT_VALUE) = StringConfigurationEntry(key, value)
fun newEntryEntry(
key: ConfigKey = KEY,
value: String = DEFAULT_VALUE
) = StringConfigurationEntry(key, value)
} }

View File

@ -8,25 +8,28 @@ import co.electriccoin.zcash.spackle.LazyWithArgument
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
object AndroidConfigurationFactory { object AndroidConfigurationFactory {
private val instance =
private val instance = LazyWithArgument<Context, ConfigurationProvider> { context -> LazyWithArgument<Context, ConfigurationProvider> { context ->
new(context) new(context)
} }
fun getInstance(context: Context): ConfigurationProvider = instance.getInstance(context) fun getInstance(context: Context): ConfigurationProvider = instance.getInstance(context)
// Context will be needed for most cloud providers, e.g. to integrate with Firebase or other // Context will be needed for most cloud providers, e.g. to integrate with Firebase or other
// remote configuration providers. // remote configuration providers.
private fun new(@Suppress("UNUSED_PARAMETER") context: Context): ConfigurationProvider { private fun new(
val configurationProviders = buildList<ConfigurationProvider> { @Suppress("UNUSED_PARAMETER") context: Context
// For ordering, ensure the IntentConfigurationProvider is first so that it can ): ConfigurationProvider {
// override any other configuration providers. val configurationProviders =
if (BuildConfig.DEBUG) { buildList<ConfigurationProvider> {
add(IntentConfigurationProvider) // For ordering, ensure the IntentConfigurationProvider is first so that it can
} // override any other configuration providers.
if (BuildConfig.DEBUG) {
add(IntentConfigurationProvider)
}
// In the future, add a third party cloud-based configuration provider // In the future, add a third party cloud-based configuration provider
} }
return MergingConfigurationProvider(configurationProviders.toPersistentList()) return MergingConfigurationProvider(configurationProviders.toPersistentList())
} }

View File

@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
internal object IntentConfigurationProvider : ConfigurationProvider { internal object IntentConfigurationProvider : ConfigurationProvider {
private val configurationStateFlow = MutableStateFlow(StringConfiguration(persistentMapOf(), null)) private val configurationStateFlow = MutableStateFlow(StringConfiguration(persistentMapOf(), null))
override fun peekConfiguration() = configurationStateFlow.value override fun peekConfiguration() = configurationStateFlow.value

View File

@ -8,18 +8,22 @@ import kotlinx.collections.immutable.toPersistentMap
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
class IntentConfigurationReceiver : BroadcastReceiver() { class IntentConfigurationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(
context: Context?,
intent: Intent?
) {
intent?.defuse()?.let { intent?.defuse()?.let {
val key = it.getStringExtra(ConfigurationIntent.EXTRA_STRING_KEY) val key = it.getStringExtra(ConfigurationIntent.EXTRA_STRING_KEY)
val value = it.getStringExtra(ConfigurationIntent.EXTRA_STRING_VALUE) val value = it.getStringExtra(ConfigurationIntent.EXTRA_STRING_VALUE)
if (null != key) { if (null != key) {
val existingConfiguration = IntentConfigurationProvider.peekConfiguration().configurationMapping val existingConfiguration = IntentConfigurationProvider.peekConfiguration().configurationMapping
val newConfiguration = if (null == value) { val newConfiguration =
existingConfiguration.remove(key) if (null == value) {
} else { existingConfiguration.remove(key)
existingConfiguration + (key to value) } else {
} existingConfiguration + (key to value)
}
IntentConfigurationProvider.setConfiguration( IntentConfigurationProvider.setConfiguration(
StringConfiguration(newConfiguration.toPersistentMap(), Clock.System.now()) StringConfiguration(newConfiguration.toPersistentMap(), Clock.System.now())
@ -34,7 +38,9 @@ private fun Intent.defuse(): Intent? {
return try { return try {
extras?.containsKey(null) extras?.containsKey(null)
this this
} catch (@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception) { } catch (
@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception
) {
null null
} }
} }

View File

@ -9,7 +9,6 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class AndroidUncaughtExceptionHandlerTest { class AndroidUncaughtExceptionHandlerTest {
@Test(expected = IllegalStateException::class) @Test(expected = IllegalStateException::class)
fun requires_main_thread() { fun requires_main_thread() {
AndroidUncaughtExceptionHandler.register(ApplicationProvider.getApplicationContext()) AndroidUncaughtExceptionHandler.register(ApplicationProvider.getApplicationContext())

View File

@ -12,7 +12,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class Components { class Components {
@Test @Test
@SmallTest @SmallTest
fun process_names() { fun process_names() {
@ -26,16 +25,18 @@ class Components {
} }
} }
private fun PackageManager.getProviderInfoCompat(componentName: ComponentName) = if (AndroidApiVersion.isAtLeastT) { private fun PackageManager.getProviderInfoCompat(componentName: ComponentName) =
getProviderInfo(componentName, PackageManager.ComponentInfoFlags.of(0)) if (AndroidApiVersion.isAtLeastT) {
} else { getProviderInfo(componentName, PackageManager.ComponentInfoFlags.of(0))
@Suppress("Deprecation") } else {
getProviderInfo(componentName, 0) @Suppress("Deprecation")
} getProviderInfo(componentName, 0)
}
private fun PackageManager.getReceiverInfoCompat(componentName: ComponentName) = if (AndroidApiVersion.isAtLeastT) { private fun PackageManager.getReceiverInfoCompat(componentName: ComponentName) =
getReceiverInfo(componentName, PackageManager.ComponentInfoFlags.of(0)) if (AndroidApiVersion.isAtLeastT) {
} else { getReceiverInfo(componentName, PackageManager.ComponentInfoFlags.of(0))
@Suppress("Deprecation") } else {
getReceiverInfo(componentName, 0) @Suppress("Deprecation")
} getReceiverInfo(componentName, 0)
}

View File

@ -6,7 +6,6 @@ import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
class ReportableExceptionTest { class ReportableExceptionTest {
@Test @Test
fun bundle() { fun bundle() {
val reportableException = ReportableExceptionFixture.new() val reportableException = ReportableExceptionFixture.new()

View File

@ -9,8 +9,9 @@ import java.io.File
@Suppress("ReturnCount") @Suppress("ReturnCount")
suspend fun ExceptionPath.getExceptionDirectory(context: Context): File? { suspend fun ExceptionPath.getExceptionDirectory(context: Context): File? {
val exceptionDirectory = context.getExternalFilesDirSuspend(null) val exceptionDirectory =
?.let { File(File(it, ExceptionPath.LOG_DIRECTORY_NAME), ExceptionPath.EXCEPTION_DIRECTORY_NAME) } context.getExternalFilesDirSuspend(null)
?.let { File(File(it, ExceptionPath.LOG_DIRECTORY_NAME), ExceptionPath.EXCEPTION_DIRECTORY_NAME) }
if (null == exceptionDirectory) { if (null == exceptionDirectory) {
Twig.info { "Unable to get external storage directory; external storage may not be available" } Twig.info { "Unable to get external storage directory; external storage may not be available" }
@ -27,9 +28,13 @@ suspend fun ExceptionPath.getExceptionDirectory(context: Context): File? {
return exceptionDirectory return exceptionDirectory
} }
suspend fun ExceptionPath.getExceptionPath(context: Context, exception: ReportableException): File? { suspend fun ExceptionPath.getExceptionPath(
val exceptionDirectory = getExceptionDirectory(context) context: Context,
?: return null exception: ReportableException
): File? {
val exceptionDirectory =
getExceptionDirectory(context)
?: return null
return File(exceptionDirectory, newExceptionFileName(exception)) return File(exceptionDirectory, newExceptionFileName(exception))
} }

View File

@ -11,7 +11,6 @@ import co.electriccoin.zcash.spackle.process.ProcessNameCompat
import java.util.Collections import java.util.Collections
object GlobalCrashReporter { object GlobalCrashReporter {
internal const val CRASH_PROCESS_NAME_SUFFIX = ":crash" // $NON-NLS internal const val CRASH_PROCESS_NAME_SUFFIX = ":crash" // $NON-NLS
private val intrinsicLock = Any() private val intrinsicLock = Any()
@ -33,17 +32,18 @@ object GlobalCrashReporter {
synchronized(intrinsicLock) { synchronized(intrinsicLock) {
if (registeredCrashReporters == null) { if (registeredCrashReporters == null) {
registeredCrashReporters = Collections.synchronizedList( registeredCrashReporters =
// To prevent a race condition, register the LocalCrashReporter first. Collections.synchronizedList(
// FirebaseCrashReporter does some asynchronous registration internally, while // To prevent a race condition, register the LocalCrashReporter first.
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read // FirebaseCrashReporter does some asynchronous registration internally, while
// and write the default UncaughtExceptionHandler. The only way to ensure // LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// interleaving doesn't happen is to register the LocalCrashReporter first. // and write the default UncaughtExceptionHandler. The only way to ensure
listOfNotNull( // interleaving doesn't happen is to register the LocalCrashReporter first.
LocalCrashReporter.getInstance(context), listOfNotNull(
FirebaseCrashReporter(context), LocalCrashReporter.getInstance(context),
FirebaseCrashReporter(context),
)
) )
)
} }
} }

View File

@ -3,7 +3,6 @@ package co.electriccoin.zcash.crash.android.internal
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
interface CrashReporter { interface CrashReporter {
/** /**
* Report a caught exception, e.g. within a try-catch. * Report a caught exception, e.g. within a try-catch.
*/ */

View File

@ -30,9 +30,10 @@ object FirebaseAppCache {
} }
} }
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer = withContext(Dispatchers.IO) { private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer =
val firebaseApp = FirebaseApp.initializeApp(context) withContext(Dispatchers.IO) {
FirebaseAppContainer(firebaseApp) val firebaseApp = FirebaseApp.initializeApp(context)
} FirebaseAppContainer(firebaseApp)
}
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?) private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)

View File

@ -25,13 +25,13 @@ import kotlinx.coroutines.async
internal class FirebaseCrashReporter( internal class FirebaseCrashReporter(
context: Context context: Context
) : CrashReporter { ) : CrashReporter {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val initFirebaseJob: Deferred<CrashReporter?> = analyticsScope.async { private val initFirebaseJob: Deferred<CrashReporter?> =
FirebaseCrashReporterImpl.getInstance(context) analyticsScope.async {
} FirebaseCrashReporterImpl.getInstance(context)
}
@AnyThread @AnyThread
override fun reportCaughtException(exception: Throwable) { override fun reportCaughtException(exception: Throwable) {
@ -67,7 +67,6 @@ private class FirebaseCrashReporterImpl(
private val firebaseCrashlytics: FirebaseCrashlytics, private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebaseInstallations: FirebaseInstallations private val firebaseInstallations: FirebaseInstallations
) : CrashReporter { ) : CrashReporter {
@AnyThread @AnyThread
override fun reportCaughtException(exception: Throwable) { override fun reportCaughtException(exception: Throwable) {
firebaseCrashlytics.recordException(exception) firebaseCrashlytics.recordException(exception)
@ -90,30 +89,32 @@ private class FirebaseCrashReporterImpl(
* early crashes may be missed. This is a tradeoff we are willing to make in order to avoid * early crashes may be missed. This is a tradeoff we are willing to make in order to avoid
* ANRs. * ANRs.
*/ */
private val lazyWithArgument = SuspendingLazy<Context, CrashReporter?> { private val lazyWithArgument =
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) { SuspendingLazy<Context, CrashReporter?> {
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
// Workaround for disk IO on main thread in Firebase initialization // Workaround for disk IO on main thread in Firebase initialization
val firebaseApp = FirebaseAppCache.getFirebaseApp(it) val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) { if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" } Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
return@SuspendingLazy null return@SuspendingLazy null
}
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
val firebaseCrashlytics =
FirebaseCrashlytics.getInstance().apply {
setCustomKey(
CrashlyticsUserProperties.IS_TEST,
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
)
}
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
} else {
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
null
} }
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
val firebaseCrashlytics = FirebaseCrashlytics.getInstance().apply {
setCustomKey(
CrashlyticsUserProperties.IS_TEST,
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
)
}
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
} else {
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
null
} }
}
suspend fun getInstance(context: Context): CrashReporter? { suspend fun getInstance(context: Context): CrashReporter? {
return lazyWithArgument.getInstance(context) return lazyWithArgument.getInstance(context)

View File

@ -8,9 +8,13 @@ import co.electriccoin.zcash.crash.android.getExceptionPath
import co.electriccoin.zcash.crash.write import co.electriccoin.zcash.crash.write
internal object AndroidExceptionReporter { internal object AndroidExceptionReporter {
internal suspend fun reportException(context: Context, reportableException: ReportableException) { internal suspend fun reportException(
val exceptionPath = ExceptionPath.getExceptionPath(context, reportableException) context: Context,
?: return reportableException: ReportableException
) {
val exceptionPath =
ExceptionPath.getExceptionPath(context, reportableException)
?: return
reportableException.write(exceptionPath) reportableException.write(exceptionPath)

View File

@ -13,8 +13,9 @@ internal fun ReportableException.Companion.new(
isUncaught: Boolean, isUncaught: Boolean,
clock: Clock = Clock.System clock: Clock = Clock.System
): ReportableException { ): ReportableException {
val versionName = context.packageManager.getPackageInfoCompat(context.packageName, 0L).versionName val versionName =
?: "null" context.packageManager.getPackageInfoCompat(context.packageName, 0L).versionName
?: "null"
return ReportableException( return ReportableException(
throwable.javaClass.name, throwable.javaClass.name,
@ -25,15 +26,16 @@ internal fun ReportableException.Companion.new(
) )
} }
internal fun ReportableException.toBundle() = Bundle().apply { internal fun ReportableException.toBundle() =
// Although Exception is Serializable, some Kotlin Coroutines exception classes break this Bundle().apply {
// API contract. Therefore we have to convert to a string here. // Although Exception is Serializable, some Kotlin Coroutines exception classes break this
putSerializable(ReportableException.EXTRA_STRING_CLASS_NAME, exceptionClass) // API contract. Therefore we have to convert to a string here.
putSerializable(ReportableException.EXTRA_STRING_TRACE, exceptionTrace) putSerializable(ReportableException.EXTRA_STRING_CLASS_NAME, exceptionClass)
putString(ReportableException.EXTRA_STRING_APP_VERSION, appVersion) putSerializable(ReportableException.EXTRA_STRING_TRACE, exceptionTrace)
putBoolean(ReportableException.EXTRA_BOOLEAN_IS_UNCAUGHT, isUncaught) putString(ReportableException.EXTRA_STRING_APP_VERSION, appVersion)
putLong(ReportableException.EXTRA_LONG_WALLTIME_MILLIS, time.toEpochMilliseconds()) putBoolean(ReportableException.EXTRA_BOOLEAN_IS_UNCAUGHT, isUncaught)
} putLong(ReportableException.EXTRA_LONG_WALLTIME_MILLIS, time.toEpochMilliseconds())
}
internal fun ReportableException.Companion.fromBundle(bundle: Bundle): ReportableException { internal fun ReportableException.Companion.fromBundle(bundle: Bundle): ReportableException {
val className = bundle.getString(EXTRA_STRING_CLASS_NAME)!! val className = bundle.getString(EXTRA_STRING_CLASS_NAME)!!

View File

@ -12,14 +12,17 @@ internal class AndroidUncaughtExceptionHandler(
context: Context, context: Context,
private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler
) : Thread.UncaughtExceptionHandler { ) : Thread.UncaughtExceptionHandler {
private val applicationContext = context.applicationContext private val applicationContext = context.applicationContext
override fun uncaughtException(t: Thread, e: Throwable) { override fun uncaughtException(
t: Thread,
e: Throwable
) {
val reportableException = ReportableException.new(applicationContext, e, true) val reportableException = ReportableException.new(applicationContext, e, true)
val isUseSecondaryProcess = applicationContext.resources val isUseSecondaryProcess =
.getBoolean(R.bool.co_electriccoin_zcash_crash_is_use_secondary_process) applicationContext.resources
.getBoolean(R.bool.co_electriccoin_zcash_crash_is_use_secondary_process)
if (isUseSecondaryProcess) { if (isUseSecondaryProcess) {
applicationContext.sendBroadcast(ExceptionReceiver.newIntent(applicationContext, reportableException)) applicationContext.sendBroadcast(ExceptionReceiver.newIntent(applicationContext, reportableException))
@ -31,7 +34,6 @@ internal class AndroidUncaughtExceptionHandler(
} }
companion object { companion object {
private val isInitialized = AtomicBoolean(false) private val isInitialized = AtomicBoolean(false)
/** /**

View File

@ -8,16 +8,18 @@ import kotlinx.coroutines.GlobalScope
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
class ExceptionReceiver : CoroutineBroadcastReceiver(GlobalScope) { class ExceptionReceiver : CoroutineBroadcastReceiver(GlobalScope) {
override suspend fun onReceiveSuspend(
override suspend fun onReceiveSuspend(context: Context, intent: Intent) { context: Context,
val reportableException = intent.extras?.let { ReportableException.fromBundle(it) } intent: Intent
?: return ) {
val reportableException =
intent.extras?.let { ReportableException.fromBundle(it) }
?: return
AndroidExceptionReporter.reportException(context, reportableException) AndroidExceptionReporter.reportException(context, reportableException)
} }
companion object { companion object {
/** /**
* @return Explicit intent to broadcast to log the exception. * @return Explicit intent to broadcast to log the exception.
*/ */

View File

@ -14,7 +14,6 @@ import kotlinx.coroutines.launch
* Registers an exception handler to write exceptions to disk. * Registers an exception handler to write exceptions to disk.
*/ */
internal class LocalCrashReporter(private val applicationContext: Context) : CrashReporter { internal class LocalCrashReporter(private val applicationContext: Context) : CrashReporter {
private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@AnyThread @AnyThread
@ -36,10 +35,11 @@ internal class LocalCrashReporter(private val applicationContext: Context) : Cra
} }
companion object { companion object {
private val lazyWithArgument = LazyWithArgument<Context, CrashReporter> { private val lazyWithArgument =
AndroidUncaughtExceptionHandler.register(it) LazyWithArgument<Context, CrashReporter> {
LocalCrashReporter(it.applicationContext) AndroidUncaughtExceptionHandler.register(it)
} LocalCrashReporter(it.applicationContext)
}
fun getInstance(context: Context): CrashReporter { fun getInstance(context: Context): CrashReporter {
return lazyWithArgument.getInstance(context) return lazyWithArgument.getInstance(context)

View File

@ -9,6 +9,5 @@ data class ReportableException(
val isUncaught: Boolean, val isUncaught: Boolean,
val time: Instant val time: Instant
) { ) {
companion object companion object
} }

View File

@ -14,8 +14,10 @@ object ExceptionPath {
const val TYPE = "txt" const val TYPE = "txt"
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun newExceptionFileName(exception: ReportableException, uuid: UUID = UUID.randomUUID()) = fun newExceptionFileName(
"${exception.time.epochSeconds}$SEPARATOR$uuid$SEPARATOR${exception.exceptionClass}$SEPARATOR${exception.isUncaught}.$TYPE" exception: ReportableException,
uuid: UUID = UUID.randomUUID()
) = "${exception.time.epochSeconds}$SEPARATOR$uuid$SEPARATOR${exception.exceptionClass}$SEPARATOR${exception.isUncaught}.$TYPE"
// The exceptions are really just for debugging // The exceptions are really just for debugging
@Suppress("ThrowsCount") @Suppress("ThrowsCount")

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.crash package co.electriccoin.zcash.crash
@ -8,12 +8,13 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
suspend fun ReportableException.write(path: File) { suspend fun ReportableException.write(path: File) {
val exceptionString = buildString { val exceptionString =
appendLine("App version: $appVersion") buildString {
appendLine("Is uncaught: $isUncaught") appendLine("App version: $appVersion")
appendLine("Time: $time") appendLine("Is uncaught: $isUncaught")
append(exceptionTrace) appendLine("Time: $time")
} append(exceptionTrace)
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
path.writeAtomically { tempFile -> path.writeAtomically { tempFile ->

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.crash package co.electriccoin.zcash.crash

View File

@ -143,7 +143,7 @@ FULLADLE_VERSION=0.17.4
GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.3.15 GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.3.15
GRADLE_VERSIONS_PLUGIN_VERSION=0.47.0 GRADLE_VERSIONS_PLUGIN_VERSION=0.47.0
JGIT_VERSION=6.4.0.202211300538-r JGIT_VERSION=6.4.0.202211300538-r
KTLINT_VERSION=0.49.0 KTLINT_VERSION=1.0.1
ACCOMPANIST_PERMISSIONS_VERSION=0.32.0 ACCOMPANIST_PERMISSIONS_VERSION=0.32.0
ANDROIDX_ACTIVITY_VERSION=1.8.1 ANDROIDX_ACTIVITY_VERSION=1.8.1

View File

@ -4,10 +4,12 @@ import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface PreferenceProvider { interface PreferenceProvider {
suspend fun hasKey(key: PreferenceKey): Boolean suspend fun hasKey(key: PreferenceKey): Boolean
suspend fun putString(key: PreferenceKey, value: String?) suspend fun putString(
key: PreferenceKey,
value: String?
)
suspend fun getString(key: PreferenceKey): String? suspend fun getString(key: PreferenceKey): String?

View File

@ -6,19 +6,22 @@ data class BooleanPreferenceDefault(
override val key: PreferenceKey, override val key: PreferenceKey,
private val defaultValue: Boolean private val defaultValue: Boolean
) : PreferenceDefault<Boolean> { ) : PreferenceDefault<Boolean> {
@Suppress("SwallowedException") @Suppress("SwallowedException")
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let { override suspend fun getValue(preferenceProvider: PreferenceProvider) =
try { preferenceProvider.getString(key)?.let {
it.toBooleanStrict() try {
} catch (e: IllegalArgumentException) { it.toBooleanStrict()
// TODO [#32]: Log coercion failure instead of just silently returning default } catch (e: IllegalArgumentException) {
// TODO [#32]: https://github.com/Electric-Coin-Company/zashi-android/issues/32 // TODO [#32]: Log coercion failure instead of just silently returning default
defaultValue // TODO [#32]: https://github.com/Electric-Coin-Company/zashi-android/issues/32
} defaultValue
} ?: defaultValue }
} ?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Boolean) { override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: Boolean
) {
preferenceProvider.putString(key, newValue.toString()) preferenceProvider.putString(key, newValue.toString())
} }
} }

View File

@ -6,18 +6,21 @@ data class IntegerPreferenceDefault(
override val key: PreferenceKey, override val key: PreferenceKey,
private val defaultValue: Int private val defaultValue: Int
) : PreferenceDefault<Int> { ) : PreferenceDefault<Int> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getString(key)?.let {
try {
it.toInt()
} catch (e: NumberFormatException) {
// TODO [#32]: Log coercion failure instead of just silently returning default
// TODO [#32]: https://github.com/Electric-Coin-Company/zashi-android/issues/32
defaultValue
}
} ?: defaultValue
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let { override suspend fun putValue(
try { preferenceProvider: PreferenceProvider,
it.toInt() newValue: Int
} catch (e: NumberFormatException) { ) {
// TODO [#32]: Log coercion failure instead of just silently returning default
// TODO [#32]: https://github.com/Electric-Coin-Company/zashi-android/issues/32
defaultValue
}
} ?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Int) {
preferenceProvider.putString(key, newValue.toString()) preferenceProvider.putString(key, newValue.toString())
} }
} }

View File

@ -11,16 +11,16 @@ import kotlinx.coroutines.flow.map
* variation in default value. Clients define the key and default value together, rather than just * variation in default value. Clients define the key and default value together, rather than just
* the key. * the key.
*/ */
/*
* API note: the default value is not available through the public interface in order to prevent
* clients from accidentally using the default value instead of the preference value.
*
* Implementation note: although primitives would be nice, Objects don't increase memory usage much.
* The autoboxing cache solves Booleans, and Strings are already objects, so that just leaves Integers.
* Overall the number of Integer preference entries is expected to be low compared to Booleans,
* and perhaps many Integer values will also fit within the autoboxing cache.
*/
interface PreferenceDefault<T> { interface PreferenceDefault<T> {
/*
* API note: the default value is not available through the public interface in order to prevent
* clients from accidentally using the default value instead of the preference value.
*
* Implementation note: although primitives would be nice, Objects don't increase memory usage much.
* The autoboxing cache solves Booleans, and Strings are already objects, so that just leaves Integers.
* Overall the number of Integer preference entries is expected to be low compared to Booleans,
* and perhaps many Integer values will also fit within the autoboxing cache.
*/
val key: PreferenceKey val key: PreferenceKey
@ -34,14 +34,18 @@ interface PreferenceDefault<T> {
* @param preferenceProvider Provides actual preference values. * @param preferenceProvider Provides actual preference values.
* @param newValue New value to write. * @param newValue New value to write.
*/ */
suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: T) suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: T
)
/** /**
* @param preferenceProvider Provides actual preference values. * @param preferenceProvider Provides actual preference values.
* @return Flow that emits preference changes. Note that implementations should emit an initial value * @return Flow that emits preference changes. Note that implementations should emit an initial value
* indicating what was stored in the preferences, in addition to subsequent updates. * indicating what was stored in the preferences, in addition to subsequent updates.
*/ */
fun observe(preferenceProvider: PreferenceProvider): Flow<T> = preferenceProvider.observe(key) fun observe(preferenceProvider: PreferenceProvider): Flow<T> =
.map { getValue(preferenceProvider) } preferenceProvider.observe(key)
.distinctUntilChanged() .map { getValue(preferenceProvider) }
.distinctUntilChanged()
} }

View File

@ -6,11 +6,14 @@ data class StringPreferenceDefault(
override val key: PreferenceKey, override val key: PreferenceKey,
private val defaultValue: String private val defaultValue: String
) : PreferenceDefault<String> { ) : PreferenceDefault<String> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getString(key)
?: defaultValue
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key) override suspend fun putValue(
?: defaultValue preferenceProvider: PreferenceProvider,
newValue: String
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: String) { ) {
preferenceProvider.putString(key, newValue) preferenceProvider.putString(key, newValue)
} }
} }

View File

@ -16,32 +16,38 @@ class BooleanPreferenceDefaultTest {
} }
@Test @Test
fun value_default_true() = runTest { fun value_default_true() =
val entry = BooleanPreferenceDefaultFixture.newTrue() runTest {
assertTrue(entry.getValue(MockPreferenceProvider())) val entry = BooleanPreferenceDefaultFixture.newTrue()
} assertTrue(entry.getValue(MockPreferenceProvider()))
@Test
fun value_default_false() = runTest {
val entry = BooleanPreferenceDefaultFixture.newFalse()
assertFalse(entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_from_config_false() = runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider = MockPreferenceProvider {
mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to false.toString())
} }
assertFalse(entry.getValue(mockPreferenceProvider))
}
@Test @Test
fun value_from_config_true() = runTest { fun value_default_false() =
val entry = BooleanPreferenceDefaultFixture.newTrue() runTest {
val mockPreferenceProvider = MockPreferenceProvider { val entry = BooleanPreferenceDefaultFixture.newFalse()
mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to true.toString()) assertFalse(entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_from_config_false() =
runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider =
MockPreferenceProvider {
mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to false.toString())
}
assertFalse(entry.getValue(mockPreferenceProvider))
}
@Test
fun value_from_config_true() =
runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider =
MockPreferenceProvider {
mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to true.toString())
}
assertTrue(entry.getValue(mockPreferenceProvider))
} }
assertTrue(entry.getValue(mockPreferenceProvider))
}
} }

View File

@ -15,20 +15,23 @@ class IntegerPreferenceDefaultTest {
} }
@Test @Test
fun value_default() = runTest { fun value_default() =
val entry = IntegerPreferenceDefaultFixture.new() runTest {
assertEquals(IntegerPreferenceDefaultFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider())) val entry = IntegerPreferenceDefaultFixture.new()
} assertEquals(IntegerPreferenceDefaultFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
@Test
fun value_override() = runTest {
val expected = IntegerPreferenceDefaultFixture.DEFAULT_VALUE + 5
val entry = IntegerPreferenceDefaultFixture.new()
val mockPreferenceProvider = MockPreferenceProvider {
mutableMapOf(StringDefaultPreferenceFixture.KEY.key to expected.toString())
} }
assertEquals(expected, entry.getValue(mockPreferenceProvider)) @Test
} fun value_override() =
runTest {
val expected = IntegerPreferenceDefaultFixture.DEFAULT_VALUE + 5
val entry = IntegerPreferenceDefaultFixture.new()
val mockPreferenceProvider =
MockPreferenceProvider {
mutableMapOf(StringDefaultPreferenceFixture.KEY.key to expected.toString())
}
assertEquals(expected, entry.getValue(mockPreferenceProvider))
}
} }

View File

@ -14,19 +14,22 @@ class StringPreferenceDefaultTest {
} }
@Test @Test
fun value_default() = runTest { fun value_default() =
val entry = StringDefaultPreferenceFixture.new() runTest {
assertEquals(StringDefaultPreferenceFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider())) val entry = StringDefaultPreferenceFixture.new()
} assertEquals(StringDefaultPreferenceFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
@Test
fun value_override() = runTest {
val entry = StringDefaultPreferenceFixture.new()
val mockPreferenceProvider = MockPreferenceProvider {
mutableMapOf(StringDefaultPreferenceFixture.KEY.key to "override")
} }
assertEquals("override", entry.getValue(mockPreferenceProvider)) @Test
} fun value_override() =
runTest {
val entry = StringDefaultPreferenceFixture.new()
val mockPreferenceProvider =
MockPreferenceProvider {
mutableMapOf(StringDefaultPreferenceFixture.KEY.key to "override")
}
assertEquals("override", entry.getValue(mockPreferenceProvider))
}
} }

View File

@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.flow
class MockPreferenceProvider( class MockPreferenceProvider(
mutableMapFactory: () -> MutableMap<String, String?> = { mutableMapOf() } mutableMapFactory: () -> MutableMap<String, String?> = { mutableMapOf() }
) : PreferenceProvider { ) : PreferenceProvider {
private val map = mutableMapFactory() private val map = mutableMapFactory()
override suspend fun getString(key: PreferenceKey) = map[key.key] override suspend fun getString(key: PreferenceKey) = map[key.key]
@ -21,7 +20,10 @@ class MockPreferenceProvider(
override suspend fun hasKey(key: PreferenceKey) = map.containsKey(key.key) override suspend fun hasKey(key: PreferenceKey) = map.containsKey(key.key)
override suspend fun putString(key: PreferenceKey, value: String?) { override suspend fun putString(
key: PreferenceKey,
value: String?
) {
map[key.key] = value map[key.key] = value
} }
} }

View File

@ -5,6 +5,8 @@ import co.electriccoin.zcash.preference.model.entry.PreferenceKey
object BooleanPreferenceDefaultFixture { object BooleanPreferenceDefaultFixture {
val KEY = PreferenceKey("some_boolean_key") // $NON-NLS val KEY = PreferenceKey("some_boolean_key") // $NON-NLS
fun newTrue() = BooleanPreferenceDefault(KEY, true) fun newTrue() = BooleanPreferenceDefault(KEY, true)
fun newFalse() = BooleanPreferenceDefault(KEY, false) fun newFalse() = BooleanPreferenceDefault(KEY, false)
} }

View File

@ -6,6 +6,9 @@ import co.electriccoin.zcash.preference.model.entry.PreferenceKey
object IntegerPreferenceDefaultFixture { object IntegerPreferenceDefaultFixture {
val KEY = PreferenceKey("some_string_key") // $NON-NLS val KEY = PreferenceKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = 123 const val DEFAULT_VALUE = 123
fun new(preferenceKey: PreferenceKey = KEY, value: Int = DEFAULT_VALUE) =
IntegerPreferenceDefault(preferenceKey, value) fun new(
preferenceKey: PreferenceKey = KEY,
value: Int = DEFAULT_VALUE
) = IntegerPreferenceDefault(preferenceKey, value)
} }

View File

@ -6,6 +6,9 @@ import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture { object StringDefaultPreferenceFixture {
val KEY = PreferenceKey("some_string_key") // $NON-NLS val KEY = PreferenceKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun new(preferenceKey: PreferenceKey = KEY, value: String = DEFAULT_VALUE) =
StringPreferenceDefault(preferenceKey, value) fun new(
preferenceKey: PreferenceKey = KEY,
value: String = DEFAULT_VALUE
) = StringPreferenceDefault(preferenceKey, value)
} }

View File

@ -35,61 +35,70 @@ class EncryptedPreferenceProviderTest {
@Test @Test
@SmallTest @SmallTest
fun put_and_get_string() = runBlocking { fun put_and_get_string() =
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply { val preferenceProvider =
putString(StringDefaultPreferenceFixture.KEY, expectedValue) new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
} }
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
}
@Test @Test
@SmallTest @SmallTest
fun hasKey_false() = runBlocking { fun hasKey_false() =
val preferenceProvider = new() runBlocking {
val preferenceProvider = new()
assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key)) assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
} }
@Test @Test
@SmallTest @SmallTest
fun put_and_check_key() = runBlocking { fun put_and_check_key() =
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply { val preferenceProvider =
putString(StringDefaultPreferenceFixture.KEY, expectedValue) new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
} }
assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
}
// Note: this test case relies on undocumented implementation details of SharedPreferences // Note: this test case relies on undocumented implementation details of SharedPreferences
// e.g. the directory path and the fact the preferences are stored as XML // e.g. the directory path and the fact the preferences are stored as XML
@Test @Test
@SmallTest @SmallTest
fun verify_no_plaintext() = runBlocking { fun verify_no_plaintext() =
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
new().apply { new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue) putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
val text =
File(
File(ApplicationProvider.getApplicationContext<Context>().dataDir, "shared_prefs"),
"$FILENAME.xml"
).readText()
assertFalse(text.contains(expectedValue))
assertFalse(text.contains(StringDefaultPreferenceFixture.KEY.key))
} }
val text = File(
File(ApplicationProvider.getApplicationContext<Context>().dataDir, "shared_prefs"),
"$FILENAME.xml"
).readText()
assertFalse(text.contains(expectedValue))
assertFalse(text.contains(StringDefaultPreferenceFixture.KEY.key))
}
companion object { companion object {
private val FILENAME = "encrypted_preference_test" private val FILENAME = "encrypted_preference_test"
private suspend fun new() = AndroidPreferenceProvider.newEncrypted(
ApplicationProvider.getApplicationContext(), private suspend fun new() =
FILENAME AndroidPreferenceProvider.newEncrypted(
) ApplicationProvider.getApplicationContext(),
FILENAME
)
} }
} }

View File

@ -33,41 +33,48 @@ class StandardPreferenceProviderTest {
@Test @Test
@SmallTest @SmallTest
fun put_and_get_string() = runBlocking { fun put_and_get_string() =
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply { val preferenceProvider =
putString(StringDefaultPreferenceFixture.KEY, expectedValue) new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
} }
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
}
@Test @Test
@SmallTest @SmallTest
fun hasKey_false() = runBlocking { fun hasKey_false() =
val preferenceProvider = new() runBlocking {
val preferenceProvider = new()
assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key)) assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
} }
@Test @Test
@SmallTest @SmallTest
fun put_and_check_key() = runBlocking { fun put_and_check_key() =
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply { val preferenceProvider =
putString(StringDefaultPreferenceFixture.KEY, expectedValue) new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
} }
assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
}
companion object { companion object {
private val FILENAME = "encrypted_preference_test" private val FILENAME = "encrypted_preference_test"
private suspend fun new() = AndroidPreferenceProvider.newStandard(
ApplicationProvider.getApplicationContext(), private suspend fun new() =
FILENAME AndroidPreferenceProvider.newStandard(
) ApplicationProvider.getApplicationContext(),
FILENAME
)
} }
} }

View File

@ -6,6 +6,7 @@ import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture { object StringDefaultPreferenceFixture {
val KEY = PreferenceKey("some_string_key") // $NON-NLS val KEY = PreferenceKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun new( fun new(
preferenceKey: PreferenceKey = KEY, preferenceKey: PreferenceKey = KEY,
value: String = DEFAULT_VALUE value: String = DEFAULT_VALUE

View File

@ -26,21 +26,25 @@ import java.util.concurrent.Executors
* this instance lives for the lifetime of the application. Constructing multiple instances will * this instance lives for the lifetime of the application. Constructing multiple instances will
* potentially corrupt preference data and will leak resources. * potentially corrupt preference data and will leak resources.
*/ */
/*
* Implementation note: EncryptedSharedPreferences are not thread-safe, so this implementation
* confines them to a single background thread.
*/
class AndroidPreferenceProvider( class AndroidPreferenceProvider(
private val sharedPreferences: SharedPreferences, private val sharedPreferences: SharedPreferences,
private val dispatcher: CoroutineDispatcher private val dispatcher: CoroutineDispatcher
) : PreferenceProvider { ) : PreferenceProvider {
/*
* Implementation note: EncryptedSharedPreferences are not thread-safe, so this implementation
* confines them to a single background thread.
*/
override suspend fun hasKey(key: PreferenceKey) = withContext(dispatcher) { override suspend fun hasKey(key: PreferenceKey) =
sharedPreferences.contains(key.key) withContext(dispatcher) {
} sharedPreferences.contains(key.key)
}
@SuppressLint("ApplySharedPref") @SuppressLint("ApplySharedPref")
override suspend fun putString(key: PreferenceKey, value: String?) = withContext(dispatcher) { override suspend fun putString(
key: PreferenceKey,
value: String?
) = withContext(dispatcher) {
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
editor.putString(key.key, value) editor.putString(key.key, value)
@ -50,65 +54,77 @@ class AndroidPreferenceProvider(
Unit Unit
} }
override suspend fun getString(key: PreferenceKey) = withContext(dispatcher) { override suspend fun getString(key: PreferenceKey) =
sharedPreferences.getString(key.key, null) withContext(dispatcher) {
} sharedPreferences.getString(key.key, null)
}
override fun observe(key: PreferenceKey): Flow<String?> = callbackFlow<Unit> { override fun observe(key: PreferenceKey): Flow<String?> =
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> callbackFlow<Unit> {
// Callback on main thread val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread
trySend(Unit)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
// Kickstart the emissions
trySend(Unit) trySend(Unit)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
// Kickstart the emissions awaitClose {
trySend(Unit) sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
awaitClose { }.flowOn(dispatcher)
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) .map { getString(key) }
}
}.flowOn(dispatcher)
.map { getString(key) }
companion object { companion object {
suspend fun newStandard(context: Context, filename: String): PreferenceProvider { suspend fun newStandard(
context: Context,
filename: String
): PreferenceProvider {
/* /*
* Because of this line, we don't want multiple instances of this object created * Because of this line, we don't want multiple instances of this object created
* because we don't clean up the thread afterwards. * because we don't clean up the thread afterwards.
*/ */
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val sharedPreferences = withContext(singleThreadedDispatcher) { val sharedPreferences =
context.getSharedPreferences(filename, Context.MODE_PRIVATE) withContext(singleThreadedDispatcher) {
} context.getSharedPreferences(filename, Context.MODE_PRIVATE)
}
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher) return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
} }
suspend fun newEncrypted(context: Context, filename: String): PreferenceProvider { suspend fun newEncrypted(
context: Context,
filename: String
): PreferenceProvider {
/* /*
* Because of this line, we don't want multiple instances of this object created * Because of this line, we don't want multiple instances of this object created
* because we don't clean up the thread afterwards. * because we don't clean up the thread afterwards.
*/ */
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val mainKey = withContext(singleThreadedDispatcher) { val mainKey =
@Suppress("BlockingMethodInNonBlockingContext") withContext(singleThreadedDispatcher) {
MasterKey.Builder(context).apply { @Suppress("BlockingMethodInNonBlockingContext")
setKeyScheme(MasterKey.KeyScheme.AES256_GCM) MasterKey.Builder(context).apply {
}.build() setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
} }.build()
}
val sharedPreferences = withContext(singleThreadedDispatcher) { val sharedPreferences =
@Suppress("BlockingMethodInNonBlockingContext") withContext(singleThreadedDispatcher) {
EncryptedSharedPreferences.create( @Suppress("BlockingMethodInNonBlockingContext")
context, EncryptedSharedPreferences.create(
filename, context,
mainKey, filename,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, mainKey,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
) EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
} )
}
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher) return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
} }

View File

@ -8,52 +8,57 @@ import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class PercentDecimalExtTest { class PercentDecimalExtTest {
@Test
@SmallTest
fun parse_non_zero_percent_decimal_test() =
runTest {
val parsed = PercentDecimal(0.1234f).toPercentageWithDecimal()
assertEquals("12${MonetarySeparators.current().decimal}34", parsed)
}
@Test @Test
@SmallTest @SmallTest
fun parse_non_zero_percent_decimal_test() = runTest { fun parse_zero_percent_decimal_test() =
val parsed = PercentDecimal(0.1234f).toPercentageWithDecimal() runTest {
val parsed = PercentDecimal(0.0000f).toPercentageWithDecimal()
assertEquals("12${MonetarySeparators.current().decimal}34", parsed) assertEquals("0${MonetarySeparators.current().decimal}00", parsed)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_zero_percent_decimal_test() = runTest { fun parse_max_percent_decimal_test() =
val parsed = PercentDecimal(0.0000f).toPercentageWithDecimal() runTest {
val parsed = PercentDecimal(1f).toPercentageWithDecimal()
assertEquals("0${MonetarySeparators.current().decimal}00", parsed) assertEquals("100${MonetarySeparators.current().decimal}00", parsed)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_max_percent_decimal_test() = runTest { fun parse_min_percent_decimal_test() =
val parsed = PercentDecimal(1f).toPercentageWithDecimal() runTest {
val parsed = PercentDecimal(0f).toPercentageWithDecimal()
assertEquals("100${MonetarySeparators.current().decimal}00", parsed) assertEquals("0${MonetarySeparators.current().decimal}00", parsed)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_min_percent_decimal_test() = runTest { fun parse_round_down_percent_decimal_test() =
val parsed = PercentDecimal(0f).toPercentageWithDecimal() runTest {
val parsed = PercentDecimal(0.11111f).toPercentageWithDecimal()
assertEquals("0${MonetarySeparators.current().decimal}00", parsed) assertEquals("11${MonetarySeparators.current().decimal}11", parsed)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_round_down_percent_decimal_test() = runTest { fun parse_round_up_percent_decimal_test() =
val parsed = PercentDecimal(0.11111f).toPercentageWithDecimal() runTest {
val parsed = PercentDecimal(0.11119f).toPercentageWithDecimal()
assertEquals("11${MonetarySeparators.current().decimal}11", parsed) assertEquals("11${MonetarySeparators.current().decimal}12", parsed)
} }
@Test
@SmallTest
fun parse_round_up_percent_decimal_test() = runTest {
val parsed = PercentDecimal(0.11119f).toPercentageWithDecimal()
assertEquals("11${MonetarySeparators.current().decimal}12", parsed)
}
} }

View File

@ -15,7 +15,6 @@ import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class ZecRequestTest { class ZecRequestTest {
companion object { companion object {
private const val URI: String = "zcash:tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp?amount=1&message=Hello%20world!" private const val URI: String = "zcash:tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp?amount=1&message=Hello%20world!"
@ -23,100 +22,111 @@ class ZecRequestTest {
private val AMOUNT = Zatoshi(1) private val AMOUNT = Zatoshi(1)
private val MESSAGE = ZecRequestMessage("Hello world!") private val MESSAGE = ZecRequestMessage("Hello world!")
private const val ADDRESS_STRING = "tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp" private const val ADDRESS_STRING = "tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp"
private val ADDRESS: WalletAddress.Unified = runBlocking { private val ADDRESS: WalletAddress.Unified =
WalletAddress.Unified.new(ADDRESS_STRING) runBlocking {
} WalletAddress.Unified.new(ADDRESS_STRING)
}
val REQUEST = ZecRequest(ADDRESS, AMOUNT, MESSAGE) val REQUEST = ZecRequest(ADDRESS, AMOUNT, MESSAGE)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_uri_not_null() = runTest { fun parse_uri_not_null() =
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI) runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
assertNotNull(parsed) assertNotNull(parsed)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_uri_valid_result() = runTest { fun parse_uri_valid_result() =
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI) runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
assertTrue(parsed.message.value.length <= ZecRequestMessage.MAX_MESSAGE_LENGTH) assertTrue(parsed.message.value.length <= ZecRequestMessage.MAX_MESSAGE_LENGTH)
assertTrue(parsed.address.address.isNotEmpty()) assertTrue(parsed.address.address.isNotEmpty())
assertTrue(parsed.amount.value >= 0) assertTrue(parsed.amount.value >= 0)
} }
@Test @Test
@SmallTest @SmallTest
fun parse_uri_correct_result() = runTest { fun parse_uri_correct_result() =
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI) runTest {
val expected = ZecRequest( val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS), val expected =
Zip321UriParseFixture.AMOUNT, ZecRequest(
Zip321UriParseFixture.MESSAGE WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS),
) Zip321UriParseFixture.AMOUNT,
Zip321UriParseFixture.MESSAGE
)
assertEquals(parsed, expected) assertEquals(parsed, expected)
} }
@Test @Test
@SmallTest @SmallTest
// TODO [#397]: Waiting for an implementation of Uri parser in SDK project // TODO [#397]: Waiting for an implementation of Uri parser in SDK project
@Ignore("Waiting for an implementation of Uri parser in SDK project") @Ignore("Waiting for an implementation of Uri parser in SDK project")
fun parse_uri_incorrect_result() = runTest { fun parse_uri_incorrect_result() =
val parsed = ZecRequest.fromUri(URI) runTest {
val expected = REQUEST val parsed = ZecRequest.fromUri(URI)
val actual = ZecRequest( val expected = REQUEST
WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS), val actual =
Zip321UriParseFixture.AMOUNT, ZecRequest(
Zip321UriParseFixture.MESSAGE WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS),
) Zip321UriParseFixture.AMOUNT,
Zip321UriParseFixture.MESSAGE
)
assertNotEquals(parsed, expected) assertNotEquals(parsed, expected)
assertEquals(parsed, actual) assertEquals(parsed, actual)
} }
@Test @Test
@SmallTest @SmallTest
fun build_uri_not_null() = runTest { fun build_uri_not_null() =
val request = Zip321UriBuildFixture.REQUEST runTest {
val built = request.toUri() val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
assertNotNull(built) assertNotNull(built)
} }
@Test @Test
@SmallTest @SmallTest
fun build_uri_valid_result() = runTest { fun build_uri_valid_result() =
val request = Zip321UriBuildFixture.REQUEST runTest {
val built = request.toUri() val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
assertTrue(built.isNotEmpty()) assertTrue(built.isNotEmpty())
assertTrue(built.startsWith("zcash")) assertTrue(built.startsWith("zcash"))
} }
@Test @Test
@SmallTest @SmallTest
fun built_uri_correct_result() = runTest { fun built_uri_correct_result() =
val request = Zip321UriBuildFixture.REQUEST runTest {
val built = request.toUri() val request = Zip321UriBuildFixture.REQUEST
val expected = Zip321UriBuildFixture.URI val built = request.toUri()
val expected = Zip321UriBuildFixture.URI
assertEquals(built, expected) assertEquals(built, expected)
} }
@Test @Test
@SmallTest @SmallTest
// TODO [#397]: Waiting for an implementation of Uri parser in SDK project // TODO [#397]: Waiting for an implementation of Uri parser in SDK project
@Ignore("Waiting for an implementation of Uri parser in SDK project") @Ignore("Waiting for an implementation of Uri parser in SDK project")
fun build_uri_incorrect_result() = runTest { fun build_uri_incorrect_result() =
val request = Zip321UriBuildFixture.REQUEST runTest {
val built = request.toUri() val request = Zip321UriBuildFixture.REQUEST
val expected = URI val built = request.toUri()
val actual = Zip321UriBuildFixture.URI val expected = URI
val actual = Zip321UriBuildFixture.URI
assertNotEquals(built, expected) assertNotEquals(built, expected)
assertEquals(built, actual) assertEquals(built, actual)
} }
} }

View File

@ -7,7 +7,6 @@ import org.junit.Test
import kotlin.test.assertNotSame import kotlin.test.assertNotSame
class ZcashCurrencyTest { class ZcashCurrencyTest {
@SmallTest @SmallTest
@Test @Test
fun check_is_zec_type() { fun check_is_zec_type() {

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package cash.z.ecc.sdk.extension package cash.z.ecc.sdk.extension
@ -12,9 +12,10 @@ fun PercentDecimal.toPercentageWithDecimal(decimalFormat: DecimalFormat = prepar
return decimalFormat.format(decimal * 100) return decimalFormat.format(decimal * 100)
} }
private fun preparePercentDecimalFormat(): DecimalFormat = DecimalFormat().apply { private fun preparePercentDecimalFormat(): DecimalFormat =
val monetarySeparators = MonetarySeparators.current() DecimalFormat().apply {
val localizedPattern = "##0${monetarySeparators.decimal}00" val monetarySeparators = MonetarySeparators.current()
applyLocalizedPattern(localizedPattern) val localizedPattern = "##0${monetarySeparators.decimal}00"
roundingMode = RoundingMode.HALF_UP applyLocalizedPattern(localizedPattern)
} roundingMode = RoundingMode.HALF_UP
}

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package cash.z.ecc.sdk.extension package cash.z.ecc.sdk.extension
@ -6,7 +6,10 @@ import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
suspend fun Synchronizer.send(spendingKey: UnifiedSpendingKey, send: ZecSend) = sendToAddress( suspend fun Synchronizer.send(
spendingKey: UnifiedSpendingKey,
send: ZecSend
) = sendToAddress(
spendingKey, spendingKey,
send.amount, send.amount,
send.destination.address, send.destination.address,

View File

@ -9,7 +9,6 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
object PersistableWalletFixture { object PersistableWalletFixture {
val NETWORK = ZcashNetwork.Mainnet val NETWORK = ZcashNetwork.Mainnet
val ENDPOINT = LightWalletEndpoint.Mainnet val ENDPOINT = LightWalletEndpoint.Mainnet

View File

@ -3,8 +3,9 @@ package cash.z.ecc.sdk.fixture
import cash.z.ecc.android.sdk.model.SeedPhrase import cash.z.ecc.android.sdk.model.SeedPhrase
object SeedPhraseFixture { object SeedPhraseFixture {
@Suppress("MaxLineLength") const val SEED_PHRASE =
val SEED_PHRASE = "still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread" "still champion voice habit trend flight survey between bitter process artefact blind carbon " +
"truly provide dizzy crush flush breeze blouse charge solid fish spread"
fun new(seedPhrase: String = SEED_PHRASE) = SeedPhrase.new(seedPhrase) fun new(seedPhrase: String = SEED_PHRASE) = SeedPhrase.new(seedPhrase)
} }

View File

@ -9,15 +9,17 @@ import kotlinx.coroutines.runBlocking
object Zip321UriBuildFixture { object Zip321UriBuildFixture {
// TODO [#161]: Pending SDK support // TODO [#161]: Pending SDK support
const val URI: String = "zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" + const val URI: String =
"for%20your%20purchase" "zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" +
"for%20your%20purchase"
@Suppress("MagicNumber") @Suppress("MagicNumber")
val AMOUNT = Zatoshi(123) val AMOUNT = Zatoshi(123)
val MESSAGE = ZecRequestMessage("Thank you for your purchase") val MESSAGE = ZecRequestMessage("Thank you for your purchase")
val ADDRESS: WalletAddress.Unified = runBlocking { val ADDRESS: WalletAddress.Unified =
WalletAddress.Unified.new(WalletAddressFixture.UNIFIED_ADDRESS_STRING) runBlocking {
} WalletAddress.Unified.new(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
}
val REQUEST = ZecRequest(ADDRESS, AMOUNT, MESSAGE) val REQUEST = ZecRequest(ADDRESS, AMOUNT, MESSAGE)
// TODO [#397]: Waiting for an implementation of Uri parser in SDK project // TODO [#397]: Waiting for an implementation of Uri parser in SDK project

View File

@ -8,8 +8,9 @@ import cash.z.ecc.sdk.model.ZecRequestMessage
object Zip321UriParseFixture { object Zip321UriParseFixture {
// TODO [#161]: Pending SDK support // TODO [#161]: Pending SDK support
const val URI: String = "zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" + const val URI: String =
"for%20your%20purchase" "zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" +
"for%20your%20purchase"
const val ADDRESS: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING const val ADDRESS: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING
@ -20,7 +21,5 @@ object Zip321UriParseFixture {
// TODO [#397]: Waiting for an implementation of Uri parser in SDK project // TODO [#397]: Waiting for an implementation of Uri parser in SDK project
// Should return ZecRequest.fromUri(toParse) ideally, but it'd end up with an infinite loop for now. // Should return ZecRequest.fromUri(toParse) ideally, but it'd end up with an infinite loop for now.
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
suspend fun new( suspend fun new(toParse: String = URI) = ZecRequest(WalletAddress.Unified.new(ADDRESS), AMOUNT, MESSAGE)
toParse: String = URI
) = ZecRequest(WalletAddress.Unified.new(ADDRESS), AMOUNT, MESSAGE)
} }

View File

@ -10,8 +10,11 @@ import java.util.Locale
// there as part of creating the object // there as part of creating the object
sealed class SeedPhraseValidation { sealed class SeedPhraseValidation {
object BadCount : SeedPhraseValidation() object BadCount : SeedPhraseValidation()
object BadWord : SeedPhraseValidation() object BadWord : SeedPhraseValidation()
object FailedChecksum : SeedPhraseValidation() object FailedChecksum : SeedPhraseValidation()
class Valid(val seedPhrase: SeedPhrase) : SeedPhraseValidation() class Valid(val seedPhrase: SeedPhrase) : SeedPhraseValidation()
companion object { companion object {

View File

@ -6,7 +6,6 @@ import cash.z.ecc.sdk.fixture.Zip321UriBuildFixture
import cash.z.ecc.sdk.fixture.Zip321UriParseFixture import cash.z.ecc.sdk.fixture.Zip321UriParseFixture
data class ZecRequest(val address: WalletAddress.Unified, val amount: Zatoshi, val message: ZecRequestMessage) { data class ZecRequest(val address: WalletAddress.Unified, val amount: Zatoshi, val message: ZecRequestMessage) {
// TODO [#397]: Waiting for an implementation of Uri parser in SDK project // TODO [#397]: Waiting for an implementation of Uri parser in SDK project
// TODO [#397]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/397 // TODO [#397]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/397
suspend fun toUri(): String { suspend fun toUri(): String {

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package cash.z.ecc.sdk.type package cash.z.ecc.sdk.type
@ -21,6 +21,7 @@ import cash.z.ecc.sdk.ext.R
* - Using a ContentProvider for dynamic injection, where the URI is defined * - Using a ContentProvider for dynamic injection, where the URI is defined
* - Using AndroidManifest metadata for dynamic injection * - Using AndroidManifest metadata for dynamic injection
*/ */
/** /**
* @return Zcash network determined from resources. A resource overlay of [R.bool.zcash_is_testnet] * @return Zcash network determined from resources. A resource overlay of [R.bool.zcash_is_testnet]
* can be used for different build variants to change the network type. * can be used for different build variants to change the network type.

View File

@ -13,16 +13,19 @@ class AbstractProcessNameContentProviderTest {
@SmallTest @SmallTest
fun getProcessName_from_provider_info() { fun getProcessName_from_provider_info() {
val expectedApplicationProcessName = "beep" // $NON-NLS val expectedApplicationProcessName = "beep" // $NON-NLS
val ctx: ContextWrapper = object : ContextWrapper(ApplicationProvider.getApplicationContext()) { val ctx: ContextWrapper =
override fun getApplicationInfo() = ApplicationInfo().apply { object : ContextWrapper(ApplicationProvider.getApplicationContext()) {
processName = expectedApplicationProcessName override fun getApplicationInfo() =
ApplicationInfo().apply {
processName = expectedApplicationProcessName
}
} }
}
val actualProcessName = AbstractProcessNameContentProvider.getProcessNameLegacy( val actualProcessName =
ctx, AbstractProcessNameContentProvider.getProcessNameLegacy(
ProviderInfo() ctx,
) ProviderInfo()
)
assertEquals(expectedApplicationProcessName, actualProcessName) assertEquals(expectedApplicationProcessName, actualProcessName)
} }

View File

@ -13,13 +13,14 @@ class VersionCodeCompatTest {
fun versionCodeCompat() { fun versionCodeCompat() {
val expectedVersionCode = 123L val expectedVersionCode = 123L
val packageInfo = PackageInfo().apply { val packageInfo =
@Suppress("Deprecation") PackageInfo().apply {
versionCode = expectedVersionCode.toInt() @Suppress("Deprecation")
if (AndroidApiVersion.isAtLeastT) { versionCode = expectedVersionCode.toInt()
longVersionCode = expectedVersionCode if (AndroidApiVersion.isAtLeastT) {
longVersionCode = expectedVersionCode
}
} }
}
assertEquals(expectedVersionCode, packageInfo.versionCodeCompat) assertEquals(expectedVersionCode, packageInfo.versionCodeCompat)
} }

View File

@ -11,7 +11,9 @@ object AndroidApiVersion {
* [sdk]. * [sdk].
*/ */
@ChecksSdkIntAtLeast(parameter = 0) @ChecksSdkIntAtLeast(parameter = 0)
fun isAtLeast(@IntRange(from = Build.VERSION_CODES.BASE.toLong()) sdk: Int): Boolean { fun isAtLeast(
@IntRange(from = Build.VERSION_CODES.BASE.toLong()) sdk: Int
): Boolean {
return Build.VERSION.SDK_INT >= sdk return Build.VERSION.SDK_INT >= sdk
} }

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.spackle package co.electriccoin.zcash.spackle
@ -7,6 +7,7 @@ import android.content.ClipboardManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
suspend fun ClipboardManager.setPrimaryClipSuspend(data: ClipData) = withContext(Dispatchers.IO) { suspend fun ClipboardManager.setPrimaryClipSuspend(data: ClipData) =
setPrimaryClip(data) withContext(Dispatchers.IO) {
} setPrimaryClip(data)
}

View File

@ -7,7 +7,6 @@ import android.widget.Toast
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
object ClipboardManagerUtil { object ClipboardManagerUtil {
fun copyToClipboard( fun copyToClipboard(
context: Context, context: Context,
label: String, label: String,
@ -15,10 +14,11 @@ object ClipboardManagerUtil {
) { ) {
Twig.info { "Copied to clipboard: label: $label, value: $value" } Twig.info { "Copied to clipboard: label: $label, value: $value" }
val clipboardManager = context.getSystemService(ClipboardManager::class.java) val clipboardManager = context.getSystemService(ClipboardManager::class.java)
val data = ClipData.newPlainText( val data =
label, ClipData.newPlainText(
value label,
) value
)
if (AndroidApiVersion.isAtLeastT) { if (AndroidApiVersion.isAtLeastT) {
// API 33 and later implement their system Toast UI. // API 33 and later implement their system Toast UI.
clipboardManager.setPrimaryClip(data) clipboardManager.setPrimaryClip(data)

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename") @file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.spackle package co.electriccoin.zcash.spackle
@ -6,6 +6,7 @@ import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
suspend fun Context.getExternalFilesDirSuspend(type: String?) = withContext(Dispatchers.IO) { suspend fun Context.getExternalFilesDirSuspend(type: String?) =
getExternalFilesDir(type) withContext(Dispatchers.IO) {
} getExternalFilesDir(type)
}

View File

@ -11,7 +11,10 @@ import kotlinx.coroutines.launch
* It is not recommended to cancel this scope. * It is not recommended to cancel this scope.
*/ */
abstract class CoroutineBroadcastReceiver(private val broadcastReceiverScope: CoroutineScope) : BroadcastReceiver() { abstract class CoroutineBroadcastReceiver(private val broadcastReceiverScope: CoroutineScope) : BroadcastReceiver() {
final override fun onReceive(context: Context, intent: Intent) { final override fun onReceive(
context: Context,
intent: Intent
) {
val pendingResult = goAsync() val pendingResult = goAsync()
broadcastReceiverScope.launch { broadcastReceiverScope.launch {
@ -29,5 +32,8 @@ abstract class CoroutineBroadcastReceiver(private val broadcastReceiverScope: Co
* the Android timeout for broadcast receivers. This method is suitable for brief disk IO but * the Android timeout for broadcast receivers. This method is suitable for brief disk IO but
* not suitable for network calls. * not suitable for network calls.
*/ */
abstract suspend fun onReceiveSuspend(context: Context, intent: Intent) abstract suspend fun onReceiveSuspend(
context: Context,
intent: Intent
)
} }

View File

@ -11,9 +11,10 @@ object EmulatorWtfUtil {
private const val EMULATOR_WTF_SETTING = "emulator.wtf" // $NON-NLS private const val EMULATOR_WTF_SETTING = "emulator.wtf" // $NON-NLS
private const val SETTING_TRUE = "true" // $NON-NLS private const val SETTING_TRUE = "true" // $NON-NLS
private val isEmulatorWtfCached = LazyWithArgument<Context, Boolean> { private val isEmulatorWtfCached =
isEmulatorWtfImpl(it) LazyWithArgument<Context, Boolean> {
} isEmulatorWtfImpl(it)
}
/** /**
* @return True if the environment is emulator.wtf * @return True if the environment is emulator.wtf

View File

@ -11,9 +11,10 @@ object FirebaseTestLabUtil {
private const val FIREBASE_TEST_LAB_SETTING = "firebase.test.lab" // $NON-NLS private const val FIREBASE_TEST_LAB_SETTING = "firebase.test.lab" // $NON-NLS
private const val SETTING_TRUE = "true" // $NON-NLS private const val SETTING_TRUE = "true" // $NON-NLS
private val isFirebaseTestLabCached = LazyWithArgument<Context, Boolean> { private val isFirebaseTestLabCached =
isFirebaseTestLabImpl(it) LazyWithArgument<Context, Boolean> {
} isFirebaseTestLabImpl(it)
}
/** /**
* @return True if the environment is Firebase Test Lab. * @return True if the environment is Firebase Test Lab.
@ -24,10 +25,10 @@ object FirebaseTestLabUtil {
/* /*
* Per the documentation at https://firebase.google.com/docs/test-lab/android-studio * Per the documentation at https://firebase.google.com/docs/test-lab/android-studio
*/ */
// Tested with the benchmark library, this is very fast. There shouldn't be a need to make
// this a suspend function. That said, we'll still cache the result as a just-in-case
// since IPC may be involved.
return runCatching { return runCatching {
// Tested with the benchmark library, this is very fast. There shouldn't be a need to make
// this a suspend function. That said, we'll still cache the result as a just-in-case
// since IPC may be involved.
SETTING_TRUE == Settings.System.getString(context.contentResolver, FIREBASE_TEST_LAB_SETTING) SETTING_TRUE == Settings.System.getString(context.contentResolver, FIREBASE_TEST_LAB_SETTING)
}.recover { }.recover {
// Fail-safe in case an error occurs // Fail-safe in case an error occurs

View File

@ -7,14 +7,20 @@ import android.os.Build
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Long): PackageInfo = fun PackageManager.getPackageInfoCompat(
packageName: String,
flags: Long
): PackageInfo =
if (AndroidApiVersion.isAtLeastT) { if (AndroidApiVersion.isAtLeastT) {
getPackageInfoTPlus(packageName, flags) getPackageInfoTPlus(packageName, flags)
} else { } else {
getPackageInfoLegacy(packageName, flags) getPackageInfoLegacy(packageName, flags)
} }
suspend fun PackageManager.getPackageInfoCompatSuspend(packageName: String, flags: Long): PackageInfo = suspend fun PackageManager.getPackageInfoCompatSuspend(
packageName: String,
flags: Long
): PackageInfo =
if (AndroidApiVersion.isAtLeastT) { if (AndroidApiVersion.isAtLeastT) {
withContext(Dispatchers.IO) { getPackageInfoTPlus(packageName, flags) } withContext(Dispatchers.IO) { getPackageInfoTPlus(packageName, flags) }
} else { } else {
@ -22,9 +28,13 @@ suspend fun PackageManager.getPackageInfoCompatSuspend(packageName: String, flag
} }
@TargetApi(Build.VERSION_CODES.TIRAMISU) @TargetApi(Build.VERSION_CODES.TIRAMISU)
private fun PackageManager.getPackageInfoTPlus(packageName: String, flags: Long) = private fun PackageManager.getPackageInfoTPlus(
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags)) packageName: String,
flags: Long
) = getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags))
@Suppress("Deprecation") @Suppress("Deprecation")
private fun PackageManager.getPackageInfoLegacy(packageName: String, flags: Long) = private fun PackageManager.getPackageInfoLegacy(
getPackageInfo(packageName, flags.toInt()) packageName: String,
flags: Long
) = getPackageInfo(packageName, flags.toInt())

View File

@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.os.StrictMode import android.os.StrictMode
object StrictModeCompat { object StrictModeCompat {
fun enableStrictMode(isCrashOnViolation: Boolean) { fun enableStrictMode(isCrashOnViolation: Boolean) {
configureStrictMode(isCrashOnViolation) configureStrictMode(isCrashOnViolation)
} }

View File

@ -40,7 +40,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this // JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic @JvmStatic
fun verbose(throwable: Throwable, message: () -> String) { fun verbose(
throwable: Throwable,
message: () -> String
) {
Log.v(tag, formatMessage(message), throwable) Log.v(tag, formatMessage(message), throwable)
} }
@ -52,7 +55,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this // JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic @JvmStatic
fun debug(throwable: Throwable, message: () -> String) { fun debug(
throwable: Throwable,
message: () -> String
) {
Log.d(tag, formatMessage(message), throwable) Log.d(tag, formatMessage(message), throwable)
} }
@ -64,7 +70,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this // JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic @JvmStatic
fun info(throwable: Throwable, message: () -> String) { fun info(
throwable: Throwable,
message: () -> String
) {
Log.i(tag, formatMessage(message), throwable) Log.i(tag, formatMessage(message), throwable)
} }
@ -76,7 +85,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this // JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic @JvmStatic
fun warn(throwable: Throwable, message: () -> String) { fun warn(
throwable: Throwable,
message: () -> String
) {
Log.w(tag, formatMessage(message), throwable) Log.w(tag, formatMessage(message), throwable)
} }
@ -88,7 +100,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this // JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic @JvmStatic
fun error(throwable: Throwable, message: () -> String) { fun error(
throwable: Throwable,
message: () -> String
) {
Log.e(tag, formatMessage(message), throwable) Log.e(tag, formatMessage(message), throwable)
} }
@ -96,11 +111,12 @@ object Twig {
* Can be called in a release build to test that `assumenosideeffects` ProGuard rules have been * Can be called in a release build to test that `assumenosideeffects` ProGuard rules have been
* properly processed to strip out logging messages. * properly processed to strip out logging messages.
*/ */
// JVMStatic is to simplify ProGuard/R8 rules for stripping this @JvmStatic // JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun assertLoggingStripped() { fun assertLoggingStripped() {
@Suppress("MaxLineLength") throw AssertionError(
throw AssertionError("Logging was not disabled by ProGuard or R8. Logging should be disabled in release builds to reduce risk of sensitive information being leaked.") // $NON-NLS-1$ "Logging was not disabled by ProGuard or R8. Logging should be disabled in release builds to reduce risk " +
"of sensitive information being leaked."
) // $NON-NLS-1$
} }
private const val CALL_DEPTH = 4 private const val CALL_DEPTH = 4

View File

@ -5,11 +5,12 @@ import android.content.pm.PackageInfo
import android.os.Build import android.os.Build
val PackageInfo.versionCodeCompat val PackageInfo.versionCodeCompat
get() = if (AndroidApiVersion.isAtLeastP) { get() =
getVersionCodePPlus() if (AndroidApiVersion.isAtLeastP) {
} else { getVersionCodePPlus()
versionCodeLegacy.toLong() } else {
} versionCodeLegacy.toLong()
}
@Suppress("Deprecation") @Suppress("Deprecation")
private val PackageInfo.versionCodeLegacy private val PackageInfo.versionCodeLegacy

View File

@ -20,16 +20,20 @@ import co.electriccoin.zcash.spackle.AndroidApiVersion
open class AbstractProcessNameContentProvider : ContentProvider() { open class AbstractProcessNameContentProvider : ContentProvider() {
override fun onCreate() = true override fun onCreate() = true
override fun attachInfo(context: Context, info: ProviderInfo) { override fun attachInfo(
context: Context,
info: ProviderInfo
) {
super.attachInfo(context, info) super.attachInfo(context, info)
val processName: String = if (AndroidApiVersion.isAtLeastT) { val processName: String =
getProcessNameTPlus() if (AndroidApiVersion.isAtLeastT) {
} else if (AndroidApiVersion.isAtLeastP) { getProcessNameTPlus()
getProcessNamePPlus() } else if (AndroidApiVersion.isAtLeastP) {
} else { getProcessNamePPlus()
getProcessNameLegacy(context, info) } else {
} getProcessNameLegacy(context, info)
}
ProcessNameCompat.setProcessName(processName) ProcessNameCompat.setProcessName(processName)
} }
@ -54,11 +58,18 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
override fun insert(uri: Uri, values: ContentValues?): Uri? { override fun insert(
uri: Uri,
values: ContentValues?
): Uri? {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int { override fun delete(
uri: Uri,
selection: String?,
selectionArgs: Array<out String>?
): Int {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
@ -72,7 +83,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
} }
companion object { companion object {
internal fun getProcessNameLegacy(context: Context, info: ProviderInfo) = internal fun getProcessNameLegacy(
info.processName ?: context.applicationInfo.processName ?: context.packageName context: Context,
info: ProviderInfo
) = info.processName ?: context.applicationInfo.processName ?: context.packageName
} }
} }

View File

@ -20,7 +20,6 @@ import co.electriccoin.zcash.spackle.process.ProcessNameCompat.getProcessName
* way to get process name on older Android versions. * way to get process name on older Android versions.
*/ */
object ProcessNameCompat { object ProcessNameCompat {
// GuardedBy intrinsicLock // GuardedBy intrinsicLock
private var processName: String? = null private var processName: String? = null

View File

@ -4,7 +4,6 @@ import kotlin.test.Test
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
class ProgressTest { class ProgressTest {
@Test @Test
fun last_greater_than_zero() { fun last_greater_than_zero() {
assertFailsWith(IllegalArgumentException::class) { assertFailsWith(IllegalArgumentException::class) {

View File

@ -8,37 +8,45 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.util.UUID import java.util.UUID
suspend fun File.existsSuspend() = withContext(Dispatchers.IO) { suspend fun File.existsSuspend() =
exists() withContext(Dispatchers.IO) {
} exists()
}
suspend fun File.mkdirsSuspend() = withContext(Dispatchers.IO) { suspend fun File.mkdirsSuspend() =
mkdirs() withContext(Dispatchers.IO) {
} mkdirs()
}
suspend fun File.isDirectorySuspend() = withContext(Dispatchers.IO) { suspend fun File.isDirectorySuspend() =
isDirectory withContext(Dispatchers.IO) {
} isDirectory
}
suspend fun File.isFileSuspend() = withContext(Dispatchers.IO) { suspend fun File.isFileSuspend() =
isFile withContext(Dispatchers.IO) {
} isFile
}
suspend fun File.canWriteSuspend() = withContext(Dispatchers.IO) { suspend fun File.canWriteSuspend() =
canWrite() withContext(Dispatchers.IO) {
} canWrite()
}
suspend fun File.deleteSuspend() = withContext(Dispatchers.IO) { suspend fun File.deleteSuspend() =
delete() withContext(Dispatchers.IO) {
} delete()
}
suspend fun File.renameToSuspend(destination: File) = withContext(Dispatchers.IO) { suspend fun File.renameToSuspend(destination: File) =
renameTo(destination) withContext(Dispatchers.IO) {
} renameTo(destination)
}
suspend fun File.listFilesSuspend() = withContext(Dispatchers.IO) { suspend fun File.listFilesSuspend() =
listFiles() withContext(Dispatchers.IO) {
} listFiles()
}
/** /**
* Given an ultimate output file destination, this generates a temporary file that [action] can write to. After action * Given an ultimate output file destination, this generates a temporary file that [action] can write to. After action
@ -50,18 +58,21 @@ suspend fun File.listFilesSuspend() = withContext(Dispatchers.IO) {
* delete, rename, or do other operations in the filesystem. * delete, rename, or do other operations in the filesystem.
*/ */
suspend fun File.writeAtomically(action: (suspend (File) -> Unit)) { suspend fun File.writeAtomically(action: (suspend (File) -> Unit)) {
val tempFile = withContext(Dispatchers.IO) { val tempFile =
File(parentFile, name.newTempFileName()).also { withContext(Dispatchers.IO) {
it.deleteOnExit() File(parentFile, name.newTempFileName()).also {
it.deleteOnExit()
}
} }
}
var isWriteSuccessful = false var isWriteSuccessful = false
try { try {
action(tempFile) action(tempFile)
isWriteSuccessful = true isWriteSuccessful = true
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) { } catch (
@Suppress("TooGenericExceptionCaught") e: Exception
) {
tempFile.deleteSuspend() tempFile.deleteSuspend()
throw e throw e
} finally { } finally {

View File

@ -16,47 +16,50 @@ class WriteAtomicallyTest {
private fun newFile() = File(File("build"), "atomic_file_test-${UUID.randomUUID()}") private fun newFile() = File(File("build"), "atomic_file_test-${UUID.randomUUID()}")
@Test @Test
fun `file has temp name`() = runTest { fun `file has temp name`() =
val testFile = newFile() runTest {
try { val testFile = newFile()
testFile.writeAtomically { try {
it.writeText("test text") testFile.writeAtomically {
assertNotEquals(testFile.name, it.name) it.writeText("test text")
assertNotEquals(testFile.name, it.name)
}
} finally {
testFile.delete()
} }
} finally {
testFile.delete()
} }
}
@Test @Test
fun `temp file deleted`() = runTest { fun `temp file deleted`() =
val testFile = newFile() runTest {
try { val testFile = newFile()
var tempFile: File? = null try {
var tempFile: File? = null
testFile.writeAtomically { testFile.writeAtomically {
tempFile = it tempFile = it
it.writeText("test text") it.writeText("test text")
}
assertNotNull(tempFile)
assertFalse(tempFile!!.exists())
} finally {
testFile.delete()
} }
assertNotNull(tempFile)
assertFalse(tempFile!!.exists())
} finally {
testFile.delete()
} }
}
@Test @Test
fun `file is renamed`() = runTest { fun `file is renamed`() =
val testFile = newFile() runTest {
try { val testFile = newFile()
testFile.writeAtomically { try {
it.writeText("test text") testFile.writeAtomically {
} it.writeText("test text")
}
assertTrue(testFile.exists()) assertTrue(testFile.exists())
} finally { } finally {
testFile.delete() testFile.delete()
}
} }
}
} }

View File

@ -10,8 +10,9 @@ import org.junit.Before
* Subclass this in view unit and integration tests. This verifies that * Subclass this in view unit and integration tests. This verifies that
* prerequisites necessary for reliable UI tests are met, and it provides more useful error messages. * prerequisites necessary for reliable UI tests are met, and it provides more useful error messages.
*/ */
// Originally hoped to put this into ZcashUiTestRunner, although it causes reporting of test results to fail
open class UiTestPrerequisites { open class UiTestPrerequisites {
// Originally hoped to put this into ZcashUiTestRunner, although it causes reporting of test results to fail
@Before @Before
fun verifyPrerequisites() { fun verifyPrerequisites() {
assertScreenIsOn() assertScreenIsOn()
@ -26,8 +27,9 @@ open class UiTestPrerequisites {
} }
private fun isScreenOn(): Boolean { private fun isScreenOn(): Boolean {
val powerService = ApplicationProvider.getApplicationContext<Context>() val powerService =
.getSystemService(Context.POWER_SERVICE) as PowerManager ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerService.isInteractive return powerService.isInteractive
} }
@ -41,7 +43,7 @@ open class UiTestPrerequisites {
val keyguardService = ( val keyguardService = (
ApplicationProvider.getApplicationContext<Context>() ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager .getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
) )
return keyguardService.isKeyguardLocked return keyguardService.isKeyguardLocked
} }

View File

@ -12,8 +12,9 @@ class ZcashUiTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle?) { override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments) super.onCreate(arguments)
val powerManager = ApplicationProvider.getApplicationContext<Context>() val powerManager =
.getSystemService(Context.POWER_SERVICE) as PowerManager ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.POWER_SERVICE) as PowerManager
// There is no alternative to this deprecated API. The suggestion of a view to keep the screen // There is no alternative to this deprecated API. The suggestion of a view to keep the screen
// on won't work well for our tests. // on won't work well for our tests.

View File

@ -2,4 +2,6 @@
root = true root = true
[*.{kt,kts}] [*.{kt,kts}]
ktlint_standard_trailing-comma-on-call-site = disabled ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled ktlint_standard_trailing-comma-on-declaration-site = disabled
# When using Compose, suppress the `function-naming` rule in favor of PascalCase naming convention
ktlint_function_naming_ignore_when_annotated_with=Composable

View File

@ -18,7 +18,6 @@ import org.junit.Test
* version and later on. * version and later on.
*/ */
class BasicStartupBenchmark { class BasicStartupBenchmark {
companion object { companion object {
private const val APP_TARGET_PACKAGE_NAME = "co.electriccoin.zcash" private const val APP_TARGET_PACKAGE_NAME = "co.electriccoin.zcash"
} }
@ -27,13 +26,14 @@ class BasicStartupBenchmark {
val benchmarkRule = MacrobenchmarkRule() val benchmarkRule = MacrobenchmarkRule()
@Test @Test
fun startup() = benchmarkRule.measureRepeated( fun startup() =
packageName = APP_TARGET_PACKAGE_NAME, benchmarkRule.measureRepeated(
metrics = listOf(StartupTimingMetric()), packageName = APP_TARGET_PACKAGE_NAME,
iterations = 5, metrics = listOf(StartupTimingMetric()),
startupMode = StartupMode.COLD iterations = 5,
) { startupMode = StartupMode.COLD
pressHome() ) {
startActivityAndWait() pressHome()
} startActivityAndWait()
}
} }

View File

@ -59,10 +59,11 @@ fun PrimaryButton(
onClick: () -> Unit, onClick: () -> Unit,
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
outerPaddingValues: PaddingValues = PaddingValues( outerPaddingValues: PaddingValues =
horizontal = ZcashTheme.dimens.spacingNone, PaddingValues(
vertical = ZcashTheme.dimens.spacingSmall horizontal = ZcashTheme.dimens.spacingNone,
), vertical = ZcashTheme.dimens.spacingSmall
),
enabled: Boolean = true, enabled: Boolean = true,
buttonColor: Color = MaterialTheme.colorScheme.primary, buttonColor: Color = MaterialTheme.colorScheme.primary,
textColor: Color = MaterialTheme.colorScheme.onPrimary, textColor: Color = MaterialTheme.colorScheme.onPrimary,
@ -70,27 +71,30 @@ fun PrimaryButton(
Button( Button(
shape = RectangleShape, shape = RectangleShape,
enabled = enabled, enabled = enabled,
modifier = modifier.then(Modifier.fillMaxWidth()) modifier =
.padding(outerPaddingValues) modifier.then(Modifier.fillMaxWidth())
.shadow( .padding(outerPaddingValues)
contentColor = textColor, .shadow(
strokeColor = buttonColor, contentColor = textColor,
strokeWidth = 1.dp, strokeColor = buttonColor,
offsetX = ZcashTheme.dimens.buttonShadowOffsetX, strokeWidth = 1.dp,
offsetY = ZcashTheme.dimens.buttonShadowOffsetY, offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
spread = ZcashTheme.dimens.buttonShadowSpread, offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
) spread = ZcashTheme.dimens.buttonShadowSpread,
.translationClick( )
translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow .translationClick(
translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp // + 6dp to exactly cover the bottom shadow
) translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp,
.defaultMinSize(ZcashTheme.dimens.buttonWidth, ZcashTheme.dimens.buttonHeight) translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp
.border(1.dp, Color.Black), )
colors = buttonColors( .defaultMinSize(ZcashTheme.dimens.buttonWidth, ZcashTheme.dimens.buttonHeight)
containerColor = buttonColor, .border(1.dp, Color.Black),
disabledContainerColor = ZcashTheme.colors.disabledButtonColor, colors =
disabledContentColor = ZcashTheme.colors.disabledButtonTextColor buttonColors(
), containerColor = buttonColor,
disabledContainerColor = ZcashTheme.colors.disabledButtonColor,
disabledContentColor = ZcashTheme.colors.disabledButtonTextColor
),
onClick = onClick, onClick = onClick,
) { ) {
Text( Text(
@ -108,10 +112,11 @@ fun SecondaryButton(
onClick: () -> Unit, onClick: () -> Unit,
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
outerPaddingValues: PaddingValues = PaddingValues( outerPaddingValues: PaddingValues =
horizontal = ZcashTheme.dimens.spacingNone, PaddingValues(
vertical = ZcashTheme.dimens.spacingSmall horizontal = ZcashTheme.dimens.spacingNone,
), vertical = ZcashTheme.dimens.spacingSmall
),
enabled: Boolean = true, enabled: Boolean = true,
buttonColor: Color = MaterialTheme.colorScheme.secondary, buttonColor: Color = MaterialTheme.colorScheme.secondary,
textColor: Color = MaterialTheme.colorScheme.onSecondary, textColor: Color = MaterialTheme.colorScheme.onSecondary,
@ -119,26 +124,29 @@ fun SecondaryButton(
Button( Button(
shape = RectangleShape, shape = RectangleShape,
enabled = enabled, enabled = enabled,
modifier = modifier.then(Modifier.fillMaxWidth()) modifier =
.padding(outerPaddingValues) modifier.then(Modifier.fillMaxWidth())
.shadow( .padding(outerPaddingValues)
contentColor = textColor, .shadow(
strokeColor = textColor, contentColor = textColor,
offsetX = ZcashTheme.dimens.buttonShadowOffsetX, strokeColor = textColor,
offsetY = ZcashTheme.dimens.buttonShadowOffsetY, offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
spread = ZcashTheme.dimens.buttonShadowSpread, offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
) spread = ZcashTheme.dimens.buttonShadowSpread,
.translationClick( )
translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow .translationClick(
translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp // + 6dp to exactly cover the bottom shadow
) translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp,
.defaultMinSize(ZcashTheme.dimens.buttonWidth, ZcashTheme.dimens.buttonHeight) translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp
.border(1.dp, Color.Black), )
colors = buttonColors( .defaultMinSize(ZcashTheme.dimens.buttonWidth, ZcashTheme.dimens.buttonHeight)
containerColor = buttonColor, .border(1.dp, Color.Black),
disabledContainerColor = ZcashTheme.colors.disabledButtonColor, colors =
disabledContentColor = ZcashTheme.colors.disabledButtonTextColor buttonColors(
), containerColor = buttonColor,
disabledContainerColor = ZcashTheme.colors.disabledButtonColor,
disabledContentColor = ZcashTheme.colors.disabledButtonTextColor
),
onClick = onClick, onClick = onClick,
) { ) {
Text( Text(
@ -155,18 +163,20 @@ fun NavigationButton(
onClick: () -> Unit, onClick: () -> Unit,
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
outerPaddingValues: PaddingValues = PaddingValues( outerPaddingValues: PaddingValues =
horizontal = ZcashTheme.dimens.spacingDefault, PaddingValues(
vertical = ZcashTheme.dimens.spacingSmall horizontal = ZcashTheme.dimens.spacingDefault,
), vertical = ZcashTheme.dimens.spacingSmall
),
) { ) {
Button( Button(
shape = RectangleShape, shape = RectangleShape,
onClick = onClick, onClick = onClick,
modifier = modifier.then( modifier =
Modifier modifier.then(
.padding(outerPaddingValues) Modifier
), .padding(outerPaddingValues)
),
colors = buttonColors(containerColor = MaterialTheme.colorScheme.secondary) colors = buttonColors(containerColor = MaterialTheme.colorScheme.secondary)
) { ) {
Text( Text(
@ -183,20 +193,22 @@ fun TertiaryButton(
onClick: () -> Unit, onClick: () -> Unit,
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
outerPaddingValues: PaddingValues = PaddingValues( outerPaddingValues: PaddingValues =
horizontal = ZcashTheme.dimens.spacingDefault, PaddingValues(
vertical = ZcashTheme.dimens.spacingSmall horizontal = ZcashTheme.dimens.spacingDefault,
), vertical = ZcashTheme.dimens.spacingSmall
),
enabled: Boolean = true enabled: Boolean = true
) { ) {
Button( Button(
shape = RectangleShape, shape = RectangleShape,
onClick = onClick, onClick = onClick,
modifier = modifier.then( modifier =
Modifier modifier.then(
.fillMaxWidth() Modifier
.padding(outerPaddingValues) .fillMaxWidth()
), .padding(outerPaddingValues)
),
enabled = enabled, enabled = enabled,
elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp), elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp),
colors = buttonColors(containerColor = ZcashTheme.colors.tertiary) colors = buttonColors(containerColor = ZcashTheme.colors.tertiary)
@ -215,19 +227,21 @@ fun DangerousButton(
onClick: () -> Unit, onClick: () -> Unit,
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
outerPaddingValues: PaddingValues = PaddingValues( outerPaddingValues: PaddingValues =
horizontal = ZcashTheme.dimens.spacingDefault, PaddingValues(
vertical = ZcashTheme.dimens.spacingSmall horizontal = ZcashTheme.dimens.spacingDefault,
), vertical = ZcashTheme.dimens.spacingSmall
),
) { ) {
Button( Button(
shape = RectangleShape, shape = RectangleShape,
onClick = onClick, onClick = onClick,
modifier = modifier.then( modifier =
Modifier modifier.then(
.fillMaxWidth() Modifier
.padding(outerPaddingValues) .fillMaxWidth()
), .padding(outerPaddingValues)
),
colors = buttonColors(containerColor = ZcashTheme.colors.dangerous) colors = buttonColors(containerColor = ZcashTheme.colors.dangerous)
) { ) {
Text( Text(
@ -291,6 +305,7 @@ fun Modifier.shadow(
) )
private enum class ButtonState { Pressed, Idle } private enum class ButtonState { Pressed, Idle }
fun Modifier.translationClick( fun Modifier.translationClick(
translationX: Dp = 0.dp, translationX: Dp = 0.dp,
translationY: Dp = 0.dp translationY: Dp = 0.dp
@ -298,26 +313,30 @@ fun Modifier.translationClick(
var buttonState by remember { mutableStateOf(ButtonState.Idle) } var buttonState by remember { mutableStateOf(ButtonState.Idle) }
val translationXAnimated by animateFloatAsState( val translationXAnimated by animateFloatAsState(
targetValue = if (buttonState == ButtonState.Pressed) { targetValue =
translationX.value if (buttonState == ButtonState.Pressed) {
} else { translationX.value
0f } else {
}, 0f
},
label = "ClickTranslationXAnimation", label = "ClickTranslationXAnimation",
animationSpec = tween( animationSpec =
durationMillis = 100 tween(
) durationMillis = 100
)
) )
val translationYAnimated by animateFloatAsState( val translationYAnimated by animateFloatAsState(
targetValue = if (buttonState == ButtonState.Pressed) { targetValue =
translationY.value if (buttonState == ButtonState.Pressed) {
} else { translationY.value
0f } else {
}, 0f
},
label = "ClickTranslationYAnimation", label = "ClickTranslationYAnimation",
animationSpec = tween( animationSpec =
durationMillis = 100 tween(
) durationMillis = 100
)
) )
this this
@ -327,13 +346,14 @@ fun Modifier.translationClick(
} }
.pointerInput(buttonState) { .pointerInput(buttonState) {
awaitPointerEventScope { awaitPointerEventScope {
buttonState = if (buttonState == ButtonState.Pressed) { buttonState =
waitForUpOrCancellation() if (buttonState == ButtonState.Pressed) {
ButtonState.Idle waitForUpOrCancellation()
} else { ButtonState.Idle
awaitFirstDown(false) } else {
ButtonState.Pressed awaitFirstDown(false)
} ButtonState.Pressed
}
} }
} }
} }

View File

@ -41,19 +41,20 @@ fun CheckBox(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier modifier = modifier
) { ) {
val checkBoxModifier = Modifier val checkBoxModifier =
.padding( Modifier
top = ZcashTheme.dimens.spacingTiny, .padding(
bottom = ZcashTheme.dimens.spacingTiny, top = ZcashTheme.dimens.spacingTiny,
end = ZcashTheme.dimens.spacingTiny bottom = ZcashTheme.dimens.spacingTiny,
) end = ZcashTheme.dimens.spacingTiny
.then( )
if (checkBoxTestTag != null) { .then(
Modifier.testTag(checkBoxTestTag) if (checkBoxTestTag != null) {
} else { Modifier.testTag(checkBoxTestTag)
Modifier } else {
} Modifier
) }
)
val (checkedState, setCheckedState) = rememberSaveable { mutableStateOf(checked) } val (checkedState, setCheckedState) = rememberSaveable { mutableStateOf(checked) }
Checkbox( Checkbox(
checked = checkedState, checked = checkedState,

View File

@ -85,14 +85,16 @@ fun ChipOnSurface(
) { ) {
Surface( Surface(
shape = RectangleShape, shape = RectangleShape,
modifier = modifier modifier =
.padding(horizontal = ZcashTheme.dimens.spacingTiny) modifier
.border( .padding(horizontal = ZcashTheme.dimens.spacingTiny)
border = BorderStroke( .border(
width = ZcashTheme.dimens.chipStroke, border =
color = ZcashTheme.colors.layoutStroke BorderStroke(
) width = ZcashTheme.dimens.chipStroke,
), color = ZcashTheme.colors.layoutStroke
)
),
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
shadowElevation = ZcashTheme.dimens.chipShadowElevation, shadowElevation = ZcashTheme.dimens.chipShadowElevation,
) { ) {
@ -100,12 +102,13 @@ fun ChipOnSurface(
text = text, text = text,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSecondary, color = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier modifier =
.padding( Modifier
vertical = ZcashTheme.dimens.spacingSmall, .padding(
horizontal = ZcashTheme.dimens.spacingDefault vertical = ZcashTheme.dimens.spacingSmall,
) horizontal = ZcashTheme.dimens.spacingDefault
.testTag(CommonTag.CHIP) )
.testTag(CommonTag.CHIP)
) )
} }
} }

View File

@ -48,14 +48,16 @@ fun ChipGrid(
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Row( Row(
modifier = Modifier modifier =
.wrapContentWidth() Modifier
.clickable( .wrapContentWidth()
interactionSource = interactionSource, .clickable(
indication = null, // Disable ripple interactionSource = interactionSource,
onClick = onGridClick // Disable ripple
) indication = null,
.testTag(CommonTag.CHIP_LAYOUT) onClick = onGridClick
)
.testTag(CommonTag.CHIP_LAYOUT)
) { ) {
wordList.chunked(CHIP_GRID_COLUMN_SIZE).forEachIndexed { chunkIndex, chunk -> wordList.chunked(CHIP_GRID_COLUMN_SIZE).forEachIndexed { chunkIndex, chunk ->
// TODO [#1043]: Correctly align numbers and words on Recovery screen // TODO [#1043]: Correctly align numbers and words on Recovery screen

View File

@ -9,11 +9,15 @@ import androidx.compose.ui.graphics.RectangleShape
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Composable @Composable
fun GradientSurface(modifier: Modifier = Modifier, content: @Composable () -> Unit) { fun GradientSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Surface( Surface(
color = Color.Transparent, color = Color.Transparent,
modifier = modifier modifier =
.background(ZcashTheme.colors.surfaceGradient()), modifier
.background(ZcashTheme.colors.surfaceGradient()),
shape = RectangleShape, shape = RectangleShape,
content = content content = content
) )

View File

@ -15,7 +15,10 @@ import kotlinx.coroutines.flow.StateFlow
* for automated tests. * for automated tests.
*/ */
@Composable @Composable
fun Override(configurationOverrideFlow: StateFlow<ConfigurationOverride?>, content: @Composable () -> Unit) { fun Override(
configurationOverrideFlow: StateFlow<ConfigurationOverride?>,
content: @Composable () -> Unit
) {
val configurationOverride = configurationOverrideFlow.collectAsState().value val configurationOverride = configurationOverrideFlow.collectAsState().value
if (null == configurationOverride) { if (null == configurationOverride) {
@ -23,15 +26,16 @@ fun Override(configurationOverrideFlow: StateFlow<ConfigurationOverride?>, conte
} else { } else {
val configuration = configurationOverride.newConfiguration(LocalConfiguration.current) val configuration = configurationOverride.newConfiguration(LocalConfiguration.current)
val contextWrapper = run { val contextWrapper =
val context = LocalContext.current run {
object : ContextThemeWrapper() { val context = LocalContext.current
init { object : ContextThemeWrapper() {
attachBaseContext(context) init {
applyOverrideConfiguration(configuration) attachBaseContext(context)
applyOverrideConfiguration(configuration)
}
} }
} }
}
CompositionLocalProvider( CompositionLocalProvider(
LocalConfiguration provides configuration, LocalConfiguration provides configuration,
@ -43,15 +47,16 @@ fun Override(configurationOverrideFlow: StateFlow<ConfigurationOverride?>, conte
} }
data class ConfigurationOverride(val uiMode: UiMode?, val locale: LocaleList?) { data class ConfigurationOverride(val uiMode: UiMode?, val locale: LocaleList?) {
fun newConfiguration(fromConfiguration: Configuration) = Configuration(fromConfiguration).apply { fun newConfiguration(fromConfiguration: Configuration) =
this@ConfigurationOverride.uiMode?.let { Configuration(fromConfiguration).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or it.flag() this@ConfigurationOverride.uiMode?.let {
} uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or it.flag()
}
this@ConfigurationOverride.locale?.let { this@ConfigurationOverride.locale?.let {
setLocales(it) setLocales(it)
}
} }
}
} }
enum class UiMode { enum class UiMode {
@ -59,7 +64,8 @@ enum class UiMode {
Dark Dark
} }
private fun UiMode.flag() = when (this) { private fun UiMode.flag() =
UiMode.Light -> Configuration.UI_MODE_NIGHT_NO when (this) {
UiMode.Dark -> Configuration.UI_MODE_NIGHT_YES UiMode.Light -> Configuration.UI_MODE_NIGHT_NO
} UiMode.Dark -> Configuration.UI_MODE_NIGHT_YES
}

View File

@ -24,48 +24,53 @@ fun SwitchWithLabel(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
ConstraintLayout( ConstraintLayout(
modifier = modifier modifier =
.clickable( modifier
interactionSource = interactionSource, .clickable(
indication = null, // disable ripple interactionSource = interactionSource,
role = Role.Switch, // disable ripple
onClick = { onStateChange(!state) } indication = null,
) role = Role.Switch,
.fillMaxWidth() onClick = { onStateChange(!state) }
)
.fillMaxWidth()
) { ) {
val (text, spacer, switchButton) = createRefs() val (text, spacer, switchButton) = createRefs()
Body( Body(
text = label, text = label,
modifier = Modifier.constrainAs(text) { modifier =
top.linkTo(parent.top) Modifier.constrainAs(text) {
bottom.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(spacer.start)
width = Dimension.fillToConstraints
}
)
Spacer(
modifier = Modifier
.width(ZcashTheme.dimens.spacingDefault)
.constrainAs(spacer) {
top.linkTo(parent.top) top.linkTo(parent.top)
bottom.linkTo(parent.top) bottom.linkTo(parent.top)
start.linkTo(text.end) start.linkTo(parent.start)
end.linkTo(switchButton.start) end.linkTo(spacer.start)
width = Dimension.fillToConstraints
} }
) )
Spacer(
modifier =
Modifier
.width(ZcashTheme.dimens.spacingDefault)
.constrainAs(spacer) {
top.linkTo(parent.top)
bottom.linkTo(parent.top)
start.linkTo(text.end)
end.linkTo(switchButton.start)
}
)
Switch( Switch(
checked = state, checked = state,
onCheckedChange = { onCheckedChange = {
onStateChange(it) onStateChange(it)
}, },
modifier = Modifier.constrainAs(switchButton) { modifier =
top.linkTo(parent.top) Modifier.constrainAs(switchButton) {
bottom.linkTo(parent.top) top.linkTo(parent.top)
start.linkTo(spacer.end) bottom.linkTo(parent.top)
end.linkTo(parent.end) start.linkTo(spacer.end)
width = Dimension.wrapContent end.linkTo(parent.end)
} width = Dimension.wrapContent
}
) )
} }
} }

View File

@ -147,21 +147,23 @@ fun Reference(
onClick: () -> Unit onClick: () -> Unit
) { ) {
Box( Box(
modifier = Modifier modifier =
.wrapContentSize() Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.topAppBarActionRippleCorner)) .wrapContentSize()
.clickable { onClick() } .clip(RoundedCornerShape(ZcashTheme.dimens.topAppBarActionRippleCorner))
.clickable { onClick() }
) { ) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodyLarge style =
.merge( MaterialTheme.typography.bodyLarge
TextStyle( .merge(
color = ZcashTheme.colors.reference, TextStyle(
textAlign = textAlign, color = ZcashTheme.colors.reference,
textDecoration = TextDecoration.Underline textAlign = textAlign,
) textDecoration = TextDecoration.Underline
), )
),
modifier = modifier modifier = modifier
) )
} }

View File

@ -27,15 +27,17 @@ fun FormTextField(
leadingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors: TextFieldColors = TextFieldDefaults.colors( colors: TextFieldColors =
focusedContainerColor = Color.Transparent, TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent,
), errorContainerColor = Color.Transparent,
),
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
shape: Shape = TextFieldDefaults.shape, shape: Shape = TextFieldDefaults.shape,
withBorder: Boolean = true, // To enable border around the TextField // To enable border around the TextField
withBorder: Boolean = true,
) { ) {
TextField( TextField(
value = value, value = value,
@ -44,13 +46,14 @@ fun FormTextField(
textStyle = textStyle, textStyle = textStyle,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
colors = colors, colors = colors,
modifier = modifier.then( modifier =
if (withBorder) { modifier.then(
modifier.border(width = 1.dp, color = MaterialTheme.colorScheme.primary) if (withBorder) {
} else { modifier.border(width = 1.dp, color = MaterialTheme.colorScheme.primary)
Modifier } else {
} Modifier
), }
),
leadingIcon = leadingIcon, leadingIcon = leadingIcon,
trailingIcon = trailingIcon, trailingIcon = trailingIcon,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,

View File

@ -181,9 +181,10 @@ private fun TopBarOneVisibleActionMenuExample(
text = "Action 1", text = "Action 1",
onClick = actionCallback, onClick = actionCallback,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier.then( modifier =
Modifier.padding(all = ZcashTheme.dimens.spacingDefault) modifier.then(
) Modifier.padding(all = ZcashTheme.dimens.spacingDefault)
)
) )
} }
@ -218,10 +219,11 @@ fun SmallTopAppBar(
navigationIcon = { navigationIcon = {
backText?.let { backText?.let {
Box( Box(
modifier = Modifier modifier =
.wrapContentSize() Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.topAppBarActionRippleCorner)) .wrapContentSize()
.clickable { onBack?.run { onBack() } } .clip(RoundedCornerShape(ZcashTheme.dimens.topAppBarActionRippleCorner))
.clickable { onBack?.run { onBack() } }
) { ) {
Row( Row(
modifier = Modifier.padding(all = ZcashTheme.dimens.spacingDefault), modifier = Modifier.padding(all = ZcashTheme.dimens.spacingDefault),

View File

@ -18,30 +18,23 @@ data class Dimens(
val spacingLarge: Dp, val spacingLarge: Dp,
val spacingXlarge: Dp, val spacingXlarge: Dp,
val spacingHuge: Dp, val spacingHuge: Dp,
// List of custom spacings: // List of custom spacings:
// Button: // Button:
val buttonShadowOffsetX: Dp, val buttonShadowOffsetX: Dp,
val buttonShadowOffsetY: Dp, val buttonShadowOffsetY: Dp,
val buttonShadowSpread: Dp, val buttonShadowSpread: Dp,
val buttonWidth: Dp, val buttonWidth: Dp,
val buttonHeight: Dp, val buttonHeight: Dp,
// Chip // Chip
val chipShadowElevation: Dp, val chipShadowElevation: Dp,
val chipStroke: Dp, val chipStroke: Dp,
// TopAppBar: // TopAppBar:
val topAppBarZcashLogoHeight: Dp, val topAppBarZcashLogoHeight: Dp,
val topAppBarActionRippleCorner: Dp, val topAppBarActionRippleCorner: Dp,
// TextField: // TextField:
val textFieldDefaultHeight: Dp, val textFieldDefaultHeight: Dp,
// Any Layout: // Any Layout:
val layoutStroke: Dp, val layoutStroke: Dp,
// Screen custom spacings: // Screen custom spacings:
val inScreenZcashLogoHeight: Dp, val inScreenZcashLogoHeight: Dp,
val inScreenZcashLogoWidth: Dp, val inScreenZcashLogoWidth: Dp,
@ -49,35 +42,36 @@ data class Dimens(
val screenHorizontalSpacing: Dp, val screenHorizontalSpacing: Dp,
) )
private val defaultDimens = Dimens( private val defaultDimens =
spacingNone = 0.dp, Dimens(
spacingXtiny = 2.dp, spacingNone = 0.dp,
spacingTiny = 4.dp, spacingXtiny = 2.dp,
spacingSmall = 8.dp, spacingTiny = 4.dp,
spacingDefault = 16.dp, spacingSmall = 8.dp,
spacingLarge = 24.dp, spacingDefault = 16.dp,
spacingXlarge = 32.dp, spacingLarge = 24.dp,
spacingHuge = 64.dp, spacingXlarge = 32.dp,
buttonShadowOffsetX = 20.dp, spacingHuge = 64.dp,
buttonShadowOffsetY = 20.dp, buttonShadowOffsetX = 20.dp,
buttonShadowSpread = 10.dp, buttonShadowOffsetY = 20.dp,
buttonWidth = 230.dp, buttonShadowSpread = 10.dp,
buttonHeight = 50.dp, buttonWidth = 230.dp,
chipShadowElevation = 4.dp, buttonHeight = 50.dp,
chipStroke = 0.5.dp, chipShadowElevation = 4.dp,
topAppBarZcashLogoHeight = 24.dp, chipStroke = 0.5.dp,
topAppBarActionRippleCorner = 28.dp, topAppBarZcashLogoHeight = 24.dp,
textFieldDefaultHeight = 215.dp, topAppBarActionRippleCorner = 28.dp,
layoutStroke = 1.dp, textFieldDefaultHeight = 215.dp,
inScreenZcashLogoHeight = 100.dp, layoutStroke = 1.dp,
inScreenZcashLogoWidth = 60.dp, inScreenZcashLogoHeight = 100.dp,
inScreenZcashTextLogoHeight = 30.dp, inScreenZcashLogoWidth = 60.dp,
screenHorizontalSpacing = 64.dp, inScreenZcashTextLogoHeight = 30.dp,
) screenHorizontalSpacing = 64.dp,
)
private val normalDimens = defaultDimens private val normalDimens = defaultDimens
internal var LocalDimens = staticCompositionLocalOf { defaultDimens } internal var localDimens = staticCompositionLocalOf { defaultDimens }
/** /**
* This is a convenience way on how to provide device specification based spacings. We use Configuration from Compose * This is a convenience way on how to provide device specification based spacings. We use Configuration from Compose
@ -119,7 +113,7 @@ internal var LocalDimens = staticCompositionLocalOf { defaultDimens }
* - rounded/normal screen shape * - rounded/normal screen shape
*/ */
@Composable @Composable
internal fun ProvideDimens(content: @Composable () -> Unit,) { internal fun ProvideDimens(content: @Composable () -> Unit) {
val resultDimens = normalDimens val resultDimens = normalDimens
CompositionLocalProvider(LocalDimens provides resultDimens, content = content) CompositionLocalProvider(localDimens provides resultDimens, content = content)
} }

View File

@ -37,10 +37,12 @@ data class ExtendedColors(
val welcomeAnimationColor: Color, val welcomeAnimationColor: Color,
) { ) {
@Composable @Composable
fun surfaceGradient() = Brush.verticalGradient( fun surfaceGradient() =
colors = listOf( Brush.verticalGradient(
MaterialTheme.colorScheme.surface, colors =
ZcashTheme.colors.surfaceEnd listOf(
MaterialTheme.colorScheme.surface,
ZcashTheme.colors.surfaceEnd
)
) )
)
} }

View File

@ -31,17 +31,19 @@ fun ZcashTheme(
// IS_APP_DARK_MODE_ENABLED, whether the device's system dark mode is on or off. // IS_APP_DARK_MODE_ENABLED, whether the device's system dark mode is on or off.
val useDarkMode = forceDarkMode || (BuildConfig.IS_APP_DARK_MODE_ENABLED && isSystemInDarkTheme()) val useDarkMode = forceDarkMode || (BuildConfig.IS_APP_DARK_MODE_ENABLED && isSystemInDarkTheme())
val baseColors = if (useDarkMode) { val baseColors =
DarkColorPalette if (useDarkMode) {
} else { DarkColorPalette
LightColorPalette } else {
} LightColorPalette
}
val extendedColors = if (useDarkMode) { val extendedColors =
DarkExtendedColorPalette if (useDarkMode) {
} else { DarkExtendedColorPalette
LightExtendedColorPalette } else {
} LightExtendedColorPalette
}
CompositionLocalProvider(LocalExtendedColors provides extendedColors) { CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
ProvideDimens { ProvideDimens {
@ -72,5 +74,5 @@ object ZcashTheme {
// TODO [#808]: https://github.com/Electric-Coin-Company/zashi-android/issues/808 // TODO [#808]: https://github.com/Electric-Coin-Company/zashi-android/issues/808
val dimens: Dimens val dimens: Dimens
@Composable @Composable
get() = LocalDimens.current get() = localDimens.current
} }

Some files were not shown because too many files have changed in this diff Show More