[#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
class AndroidApiTest {
@Test
@SmallTest
fun checkTargetApi() {

View File

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

View File

@ -5,7 +5,7 @@ plugins {
val ktlint by configurations.creating
dependencies {
ktlint("com.pinterest:ktlint:${project.property("KTLINT_VERSION")}") {
ktlint("com.pinterest.ktlint:ktlint-cli:${project.property("KTLINT_VERSION")}") {
attributes {
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.
*/
interface ConfigurationProvider {
/**
* @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)
}
override fun getBoolean(key: ConfigKey, defaultValue: Boolean): Boolean {
override fun getBoolean(
key: ConfigKey,
defaultValue: Boolean
): Boolean {
return configurations.firstWithKey(key)?.let {
return it.getBoolean(key, defaultValue)
} ?: defaultValue
}
override fun getInt(key: ConfigKey, defaultValue: Int): Int {
override fun getInt(
key: ConfigKey,
defaultValue: Int
): Int {
return configurations.firstWithKey(key)?.let {
return it.getInt(key, defaultValue)
} ?: defaultValue
}
override fun getString(key: ConfigKey, defaultValue: String): String {
override fun getString(
key: ConfigKey,
defaultValue: String
): String {
return configurations.firstWithKey(key)?.let {
return it.getString(key, defaultValue)
} ?: defaultValue

View File

@ -6,7 +6,5 @@ data class BooleanConfigurationEntry(
override val key: ConfigKey,
private val defaultValue: 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
* 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> {
/*
* 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

View File

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

View File

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

View File

@ -26,7 +26,10 @@ interface Configuration {
* be returned if type coercion fails.
* @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.
@ -35,7 +38,10 @@ interface Configuration {
* be returned if type coercion fails.
* @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.
@ -44,5 +50,8 @@ interface Configuration {
* be returned if type coercion fails.
* @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>,
override val updatedAt: Instant?
) : Configuration {
override fun getBoolean(
key: ConfigKey,
defaultValue: Boolean
) = configurationMapping[key.key]?.let {
try {
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
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 {
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
defaultValue
}
} ?: defaultValue
override fun getString(key: ConfigKey, defaultValue: String) =
configurationMapping.getOrElse(key.key) { defaultValue }
override fun getString(
key: ConfigKey,
defaultValue: String
) = configurationMapping.getOrElse(key.key) { defaultValue }
override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key)
}

View File

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

View File

@ -11,7 +11,6 @@ import kotlinx.datetime.Instant
* though, making the initial mapping thread-safe.
*/
class MockConfiguration(private val configurationMapping: Map<String, String> = emptyMap()) : Configuration {
override val updatedAt: Instant? = null
override fun getBoolean(
@ -20,23 +19,32 @@ class MockConfiguration(private val configurationMapping: Map<String, String> =
) = configurationMapping[key.key]?.let {
try {
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
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 {
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
defaultValue
}
} ?: defaultValue
override fun getString(key: ConfigKey, defaultValue: String) =
configurationMapping.getOrElse(key.key) { defaultValue }
override fun getString(
key: ConfigKey,
defaultValue: String
) = configurationMapping.getOrElse(key.key) { defaultValue }
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
object BooleanDefaultEntryFixture {
val KEY = ConfigKey("some_boolean_key") // $NON-NLS
fun newTrueEntry() = BooleanConfigurationEntry(KEY, true)

View File

@ -6,5 +6,9 @@ import co.electriccoin.zcash.configuration.model.entry.IntegerConfigurationEntry
object IntegerDefaultEntryFixture {
val KEY = ConfigKey("some_string_key") // $NON-NLS
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 {
val KEY = ConfigKey("some_string_key") // $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
object AndroidConfigurationFactory {
private val instance = LazyWithArgument<Context, ConfigurationProvider> { context ->
new(context)
}
private val instance =
LazyWithArgument<Context, ConfigurationProvider> { context ->
new(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
// remote configuration providers.
private fun new(@Suppress("UNUSED_PARAMETER") context: Context): ConfigurationProvider {
val configurationProviders = buildList<ConfigurationProvider> {
// For ordering, ensure the IntentConfigurationProvider is first so that it can
// override any other configuration providers.
if (BuildConfig.DEBUG) {
add(IntentConfigurationProvider)
}
private fun new(
@Suppress("UNUSED_PARAMETER") context: Context
): ConfigurationProvider {
val configurationProviders =
buildList<ConfigurationProvider> {
// 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())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,8 +9,9 @@ import java.io.File
@Suppress("ReturnCount")
suspend fun ExceptionPath.getExceptionDirectory(context: Context): File? {
val exceptionDirectory = context.getExternalFilesDirSuspend(null)
?.let { File(File(it, ExceptionPath.LOG_DIRECTORY_NAME), ExceptionPath.EXCEPTION_DIRECTORY_NAME) }
val exceptionDirectory =
context.getExternalFilesDirSuspend(null)
?.let { File(File(it, ExceptionPath.LOG_DIRECTORY_NAME), ExceptionPath.EXCEPTION_DIRECTORY_NAME) }
if (null == exceptionDirectory) {
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
}
suspend fun ExceptionPath.getExceptionPath(context: Context, exception: ReportableException): File? {
val exceptionDirectory = getExceptionDirectory(context)
?: return null
suspend fun ExceptionPath.getExceptionPath(
context: Context,
exception: ReportableException
): File? {
val exceptionDirectory =
getExceptionDirectory(context)
?: return null
return File(exceptionDirectory, newExceptionFileName(exception))
}

View File

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

View File

@ -3,7 +3,6 @@ package co.electriccoin.zcash.crash.android.internal
import androidx.annotation.AnyThread
interface CrashReporter {
/**
* 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) {
val firebaseApp = FirebaseApp.initializeApp(context)
FirebaseAppContainer(firebaseApp)
}
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer =
withContext(Dispatchers.IO) {
val firebaseApp = FirebaseApp.initializeApp(context)
FirebaseAppContainer(firebaseApp)
}
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)

View File

@ -25,13 +25,13 @@ import kotlinx.coroutines.async
internal class FirebaseCrashReporter(
context: Context
) : CrashReporter {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val initFirebaseJob: Deferred<CrashReporter?> = analyticsScope.async {
FirebaseCrashReporterImpl.getInstance(context)
}
private val initFirebaseJob: Deferred<CrashReporter?> =
analyticsScope.async {
FirebaseCrashReporterImpl.getInstance(context)
}
@AnyThread
override fun reportCaughtException(exception: Throwable) {
@ -67,7 +67,6 @@ private class FirebaseCrashReporterImpl(
private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebaseInstallations: FirebaseInstallations
) : CrashReporter {
@AnyThread
override fun reportCaughtException(exception: Throwable) {
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
* ANRs.
*/
private val lazyWithArgument = SuspendingLazy<Context, CrashReporter?> {
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
private val lazyWithArgument =
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
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
return@SuspendingLazy null
// Workaround for disk IO on main thread in Firebase initialization
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is 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? {
return lazyWithArgument.getInstance(context)

View File

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

View File

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

View File

@ -12,14 +12,17 @@ internal class AndroidUncaughtExceptionHandler(
context: Context,
private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler
) : Thread.UncaughtExceptionHandler {
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 isUseSecondaryProcess = applicationContext.resources
.getBoolean(R.bool.co_electriccoin_zcash_crash_is_use_secondary_process)
val isUseSecondaryProcess =
applicationContext.resources
.getBoolean(R.bool.co_electriccoin_zcash_crash_is_use_secondary_process)
if (isUseSecondaryProcess) {
applicationContext.sendBroadcast(ExceptionReceiver.newIntent(applicationContext, reportableException))
@ -31,7 +34,6 @@ internal class AndroidUncaughtExceptionHandler(
}
companion object {
private val isInitialized = AtomicBoolean(false)
/**

View File

@ -8,16 +8,18 @@ import kotlinx.coroutines.GlobalScope
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
class ExceptionReceiver : CoroutineBroadcastReceiver(GlobalScope) {
override suspend fun onReceiveSuspend(context: Context, intent: Intent) {
val reportableException = intent.extras?.let { ReportableException.fromBundle(it) }
?: return
override suspend fun onReceiveSuspend(
context: Context,
intent: Intent
) {
val reportableException =
intent.extras?.let { ReportableException.fromBundle(it) }
?: return
AndroidExceptionReporter.reportException(context, reportableException)
}
companion object {
/**
* @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.
*/
internal class LocalCrashReporter(private val applicationContext: Context) : CrashReporter {
private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@AnyThread
@ -36,10 +35,11 @@ internal class LocalCrashReporter(private val applicationContext: Context) : Cra
}
companion object {
private val lazyWithArgument = LazyWithArgument<Context, CrashReporter> {
AndroidUncaughtExceptionHandler.register(it)
LocalCrashReporter(it.applicationContext)
}
private val lazyWithArgument =
LazyWithArgument<Context, CrashReporter> {
AndroidUncaughtExceptionHandler.register(it)
LocalCrashReporter(it.applicationContext)
}
fun getInstance(context: Context): CrashReporter {
return lazyWithArgument.getInstance(context)

View File

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

View File

@ -14,8 +14,10 @@ object ExceptionPath {
const val TYPE = "txt"
@Suppress("MaxLineLength")
fun newExceptionFileName(exception: ReportableException, uuid: UUID = UUID.randomUUID()) =
"${exception.time.epochSeconds}$SEPARATOR$uuid$SEPARATOR${exception.exceptionClass}$SEPARATOR${exception.isUncaught}.$TYPE"
fun newExceptionFileName(
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
@Suppress("ThrowsCount")

View File

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

View File

@ -1,4 +1,4 @@
@file:Suppress("ktlint:filename")
@file:Suppress("ktlint:standard:filename")
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
GRADLE_VERSIONS_PLUGIN_VERSION=0.47.0
JGIT_VERSION=6.4.0.202211300538-r
KTLINT_VERSION=0.49.0
KTLINT_VERSION=1.0.1
ACCOMPANIST_PERMISSIONS_VERSION=0.32.0
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
interface PreferenceProvider {
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?

View File

@ -6,19 +6,22 @@ data class BooleanPreferenceDefault(
override val key: PreferenceKey,
private val defaultValue: Boolean
) : PreferenceDefault<Boolean> {
@Suppress("SwallowedException")
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let {
try {
it.toBooleanStrict()
} catch (e: IllegalArgumentException) {
// 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 {
try {
it.toBooleanStrict()
} catch (e: IllegalArgumentException) {
// 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: Boolean) {
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: Boolean
) {
preferenceProvider.putString(key, newValue.toString())
}
}

View File

@ -6,18 +6,21 @@ data class IntegerPreferenceDefault(
override val key: PreferenceKey,
private val defaultValue: 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 {
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 putValue(preferenceProvider: PreferenceProvider, newValue: Int) {
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: Int
) {
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
* 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> {
/*
* 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
@ -34,14 +34,18 @@ interface PreferenceDefault<T> {
* @param preferenceProvider Provides actual preference values.
* @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.
* @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.
*/
fun observe(preferenceProvider: PreferenceProvider): Flow<T> = preferenceProvider.observe(key)
.map { getValue(preferenceProvider) }
.distinctUntilChanged()
fun observe(preferenceProvider: PreferenceProvider): Flow<T> =
preferenceProvider.observe(key)
.map { getValue(preferenceProvider) }
.distinctUntilChanged()
}

View File

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

View File

@ -16,32 +16,38 @@ class BooleanPreferenceDefaultTest {
}
@Test
fun value_default_true() = runTest {
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())
fun value_default_true() =
runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
assertTrue(entry.getValue(MockPreferenceProvider()))
}
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())
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
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
fun value_default() = runTest {
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())
fun value_default() =
runTest {
val entry = IntegerPreferenceDefaultFixture.new()
assertEquals(IntegerPreferenceDefaultFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
}
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
fun value_default() = runTest {
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")
fun value_default() =
runTest {
val entry = StringDefaultPreferenceFixture.new()
assertEquals(StringDefaultPreferenceFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
}
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(
mutableMapFactory: () -> MutableMap<String, String?> = { mutableMapOf() }
) : PreferenceProvider {
private val map = mutableMapFactory()
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 putString(key: PreferenceKey, value: String?) {
override suspend fun putString(
key: PreferenceKey,
value: String?
) {
map[key.key] = value
}
}

View File

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

View File

@ -6,6 +6,9 @@ import co.electriccoin.zcash.preference.model.entry.PreferenceKey
object IntegerPreferenceDefaultFixture {
val KEY = PreferenceKey("some_string_key") // $NON-NLS
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 {
val KEY = PreferenceKey("some_string_key") // $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
@SmallTest
fun put_and_get_string() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
fun put_and_get_string() =
runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
val preferenceProvider =
new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
}
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
}
@Test
@SmallTest
fun hasKey_false() = runBlocking {
val preferenceProvider = new()
fun hasKey_false() =
runBlocking {
val preferenceProvider = new()
assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
}
assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
}
@Test
@SmallTest
fun put_and_check_key() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
fun put_and_check_key() =
runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
val preferenceProvider =
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
// e.g. the directory path and the fact the preferences are stored as XML
@Test
@SmallTest
fun verify_no_plaintext() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
fun verify_no_plaintext() =
runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
new().apply {
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 {
private val FILENAME = "encrypted_preference_test"
private suspend fun new() = AndroidPreferenceProvider.newEncrypted(
ApplicationProvider.getApplicationContext(),
FILENAME
)
private suspend fun new() =
AndroidPreferenceProvider.newEncrypted(
ApplicationProvider.getApplicationContext(),
FILENAME
)
}
}

View File

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

View File

@ -6,6 +6,7 @@ import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture {
val KEY = PreferenceKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun new(
preferenceKey: PreferenceKey = KEY,
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
* 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(
private val sharedPreferences: SharedPreferences,
private val dispatcher: CoroutineDispatcher
) : 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) {
sharedPreferences.contains(key.key)
}
override suspend fun hasKey(key: PreferenceKey) =
withContext(dispatcher) {
sharedPreferences.contains(key.key)
}
@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()
editor.putString(key.key, value)
@ -50,65 +54,77 @@ class AndroidPreferenceProvider(
Unit
}
override suspend fun getString(key: PreferenceKey) = withContext(dispatcher) {
sharedPreferences.getString(key.key, null)
}
override suspend fun getString(key: PreferenceKey) =
withContext(dispatcher) {
sharedPreferences.getString(key.key, null)
}
override fun observe(key: PreferenceKey): Flow<String?> = callbackFlow<Unit> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread
override fun observe(key: PreferenceKey): Flow<String?> =
callbackFlow<Unit> {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread
trySend(Unit)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
// Kickstart the emissions
trySend(Unit)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
// Kickstart the emissions
trySend(Unit)
awaitClose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}.flowOn(dispatcher)
.map { getString(key) }
awaitClose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}.flowOn(dispatcher)
.map { getString(key) }
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 we don't clean up the thread afterwards.
*/
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val sharedPreferences = withContext(singleThreadedDispatcher) {
context.getSharedPreferences(filename, Context.MODE_PRIVATE)
}
val sharedPreferences =
withContext(singleThreadedDispatcher) {
context.getSharedPreferences(filename, Context.MODE_PRIVATE)
}
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 we don't clean up the thread afterwards.
*/
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val mainKey = withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
MasterKey.Builder(context).apply {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
}.build()
}
val mainKey =
withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
MasterKey.Builder(context).apply {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
}.build()
}
val sharedPreferences = withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
EncryptedSharedPreferences.create(
context,
filename,
mainKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
val sharedPreferences =
withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
EncryptedSharedPreferences.create(
context,
filename,
mainKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
}

View File

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

View File

@ -15,7 +15,6 @@ import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ZecRequestTest {
companion object {
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 MESSAGE = ZecRequestMessage("Hello world!")
private const val ADDRESS_STRING = "tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp"
private val ADDRESS: WalletAddress.Unified = runBlocking {
WalletAddress.Unified.new(ADDRESS_STRING)
}
private val ADDRESS: WalletAddress.Unified =
runBlocking {
WalletAddress.Unified.new(ADDRESS_STRING)
}
val REQUEST = ZecRequest(ADDRESS, AMOUNT, MESSAGE)
}
@Test
@SmallTest
fun parse_uri_not_null() = runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
fun parse_uri_not_null() =
runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
assertNotNull(parsed)
}
assertNotNull(parsed)
}
@Test
@SmallTest
fun parse_uri_valid_result() = runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
fun parse_uri_valid_result() =
runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
assertTrue(parsed.message.value.length <= ZecRequestMessage.MAX_MESSAGE_LENGTH)
assertTrue(parsed.address.address.isNotEmpty())
assertTrue(parsed.amount.value >= 0)
}
assertTrue(parsed.message.value.length <= ZecRequestMessage.MAX_MESSAGE_LENGTH)
assertTrue(parsed.address.address.isNotEmpty())
assertTrue(parsed.amount.value >= 0)
}
@Test
@SmallTest
fun parse_uri_correct_result() = runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
val expected = ZecRequest(
WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS),
Zip321UriParseFixture.AMOUNT,
Zip321UriParseFixture.MESSAGE
)
fun parse_uri_correct_result() =
runTest {
val parsed = ZecRequest.fromUri(Zip321UriParseFixture.URI)
val expected =
ZecRequest(
WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS),
Zip321UriParseFixture.AMOUNT,
Zip321UriParseFixture.MESSAGE
)
assertEquals(parsed, expected)
}
assertEquals(parsed, expected)
}
@Test
@SmallTest
// TODO [#397]: 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 {
val parsed = ZecRequest.fromUri(URI)
val expected = REQUEST
val actual = ZecRequest(
WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS),
Zip321UriParseFixture.AMOUNT,
Zip321UriParseFixture.MESSAGE
)
fun parse_uri_incorrect_result() =
runTest {
val parsed = ZecRequest.fromUri(URI)
val expected = REQUEST
val actual =
ZecRequest(
WalletAddress.Unified.new(Zip321UriParseFixture.ADDRESS),
Zip321UriParseFixture.AMOUNT,
Zip321UriParseFixture.MESSAGE
)
assertNotEquals(parsed, expected)
assertEquals(parsed, actual)
}
assertNotEquals(parsed, expected)
assertEquals(parsed, actual)
}
@Test
@SmallTest
fun build_uri_not_null() = runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
fun build_uri_not_null() =
runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
assertNotNull(built)
}
assertNotNull(built)
}
@Test
@SmallTest
fun build_uri_valid_result() = runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
fun build_uri_valid_result() =
runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
assertTrue(built.isNotEmpty())
assertTrue(built.startsWith("zcash"))
}
assertTrue(built.isNotEmpty())
assertTrue(built.startsWith("zcash"))
}
@Test
@SmallTest
fun built_uri_correct_result() = runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
val expected = Zip321UriBuildFixture.URI
fun built_uri_correct_result() =
runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
val expected = Zip321UriBuildFixture.URI
assertEquals(built, expected)
}
assertEquals(built, expected)
}
@Test
@SmallTest
// TODO [#397]: 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 {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
val expected = URI
val actual = Zip321UriBuildFixture.URI
fun build_uri_incorrect_result() =
runTest {
val request = Zip321UriBuildFixture.REQUEST
val built = request.toUri()
val expected = URI
val actual = Zip321UriBuildFixture.URI
assertNotEquals(built, expected)
assertEquals(built, actual)
}
assertNotEquals(built, expected)
assertEquals(built, actual)
}
}

View File

@ -7,7 +7,6 @@ import org.junit.Test
import kotlin.test.assertNotSame
class ZcashCurrencyTest {
@SmallTest
@Test
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
@ -12,9 +12,10 @@ fun PercentDecimal.toPercentageWithDecimal(decimalFormat: DecimalFormat = prepar
return decimalFormat.format(decimal * 100)
}
private fun preparePercentDecimalFormat(): DecimalFormat = DecimalFormat().apply {
val monetarySeparators = MonetarySeparators.current()
val localizedPattern = "##0${monetarySeparators.decimal}00"
applyLocalizedPattern(localizedPattern)
roundingMode = RoundingMode.HALF_UP
}
private fun preparePercentDecimalFormat(): DecimalFormat =
DecimalFormat().apply {
val monetarySeparators = MonetarySeparators.current()
val localizedPattern = "##0${monetarySeparators.decimal}00"
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
@ -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.ZecSend
suspend fun Synchronizer.send(spendingKey: UnifiedSpendingKey, send: ZecSend) = sendToAddress(
suspend fun Synchronizer.send(
spendingKey: UnifiedSpendingKey,
send: ZecSend
) = sendToAddress(
spendingKey,
send.amount,
send.destination.address,

View File

@ -9,7 +9,6 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
object PersistableWalletFixture {
val NETWORK = ZcashNetwork.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
object SeedPhraseFixture {
@Suppress("MaxLineLength")
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"
const 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"
fun new(seedPhrase: String = SEED_PHRASE) = SeedPhrase.new(seedPhrase)
}

View File

@ -9,15 +9,17 @@ import kotlinx.coroutines.runBlocking
object Zip321UriBuildFixture {
// TODO [#161]: Pending SDK support
const val URI: String = "zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" +
"for%20your%20purchase"
const val URI: String =
"zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" +
"for%20your%20purchase"
@Suppress("MagicNumber")
val AMOUNT = Zatoshi(123)
val MESSAGE = ZecRequestMessage("Thank you for your purchase")
val ADDRESS: WalletAddress.Unified = runBlocking {
WalletAddress.Unified.new(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
}
val ADDRESS: WalletAddress.Unified =
runBlocking {
WalletAddress.Unified.new(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
}
val REQUEST = ZecRequest(ADDRESS, AMOUNT, MESSAGE)
// 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 {
// TODO [#161]: Pending SDK support
const val URI: String = "zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" +
"for%20your%20purchase"
const val URI: String =
"zcash:Unified%20GitHub%20Issue%20#161?amount=123&message=Thank%20you%20" +
"for%20your%20purchase"
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
// Should return ZecRequest.fromUri(toParse) ideally, but it'd end up with an infinite loop for now.
@Suppress("UNUSED_PARAMETER")
suspend fun new(
toParse: String = URI
) = ZecRequest(WalletAddress.Unified.new(ADDRESS), AMOUNT, MESSAGE)
suspend fun new(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
sealed class SeedPhraseValidation {
object BadCount : SeedPhraseValidation()
object BadWord : SeedPhraseValidation()
object FailedChecksum : SeedPhraseValidation()
class Valid(val seedPhrase: SeedPhrase) : SeedPhraseValidation()
companion object {

View File

@ -6,7 +6,6 @@ import cash.z.ecc.sdk.fixture.Zip321UriBuildFixture
import cash.z.ecc.sdk.fixture.Zip321UriParseFixture
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]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/397
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
@ -21,6 +21,7 @@ import cash.z.ecc.sdk.ext.R
* - Using a ContentProvider for dynamic injection, where the URI is defined
* - Using AndroidManifest metadata for dynamic injection
*/
/**
* @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.

View File

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

View File

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

View File

@ -11,7 +11,9 @@ object AndroidApiVersion {
* [sdk].
*/
@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
}

View File

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

View File

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

View File

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

View File

@ -11,7 +11,10 @@ import kotlinx.coroutines.launch
* It is not recommended to cancel this scope.
*/
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()
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
* 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 SETTING_TRUE = "true" // $NON-NLS
private val isEmulatorWtfCached = LazyWithArgument<Context, Boolean> {
isEmulatorWtfImpl(it)
}
private val isEmulatorWtfCached =
LazyWithArgument<Context, Boolean> {
isEmulatorWtfImpl(it)
}
/**
* @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 SETTING_TRUE = "true" // $NON-NLS
private val isFirebaseTestLabCached = LazyWithArgument<Context, Boolean> {
isFirebaseTestLabImpl(it)
}
private val isFirebaseTestLabCached =
LazyWithArgument<Context, Boolean> {
isFirebaseTestLabImpl(it)
}
/**
* @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
*/
// 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 {
// 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)
}.recover {
// Fail-safe in case an error occurs

View File

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

View File

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

View File

@ -40,7 +40,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun verbose(throwable: Throwable, message: () -> String) {
fun verbose(
throwable: Throwable,
message: () -> String
) {
Log.v(tag, formatMessage(message), throwable)
}
@ -52,7 +55,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun debug(throwable: Throwable, message: () -> String) {
fun debug(
throwable: Throwable,
message: () -> String
) {
Log.d(tag, formatMessage(message), throwable)
}
@ -64,7 +70,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun info(throwable: Throwable, message: () -> String) {
fun info(
throwable: Throwable,
message: () -> String
) {
Log.i(tag, formatMessage(message), throwable)
}
@ -76,7 +85,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun warn(throwable: Throwable, message: () -> String) {
fun warn(
throwable: Throwable,
message: () -> String
) {
Log.w(tag, formatMessage(message), throwable)
}
@ -88,7 +100,10 @@ object Twig {
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun error(throwable: Throwable, message: () -> String) {
fun error(
throwable: Throwable,
message: () -> String
) {
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
* properly processed to strip out logging messages.
*/
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
@JvmStatic // JVMStatic is to simplify ProGuard/R8 rules for stripping this
fun assertLoggingStripped() {
@Suppress("MaxLineLength")
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$
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$
}
private const val CALL_DEPTH = 4

View File

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

View File

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

View File

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

View File

@ -8,37 +8,45 @@ import java.io.File
import java.io.IOException
import java.util.UUID
suspend fun File.existsSuspend() = withContext(Dispatchers.IO) {
exists()
}
suspend fun File.existsSuspend() =
withContext(Dispatchers.IO) {
exists()
}
suspend fun File.mkdirsSuspend() = withContext(Dispatchers.IO) {
mkdirs()
}
suspend fun File.mkdirsSuspend() =
withContext(Dispatchers.IO) {
mkdirs()
}
suspend fun File.isDirectorySuspend() = withContext(Dispatchers.IO) {
isDirectory
}
suspend fun File.isDirectorySuspend() =
withContext(Dispatchers.IO) {
isDirectory
}
suspend fun File.isFileSuspend() = withContext(Dispatchers.IO) {
isFile
}
suspend fun File.isFileSuspend() =
withContext(Dispatchers.IO) {
isFile
}
suspend fun File.canWriteSuspend() = withContext(Dispatchers.IO) {
canWrite()
}
suspend fun File.canWriteSuspend() =
withContext(Dispatchers.IO) {
canWrite()
}
suspend fun File.deleteSuspend() = withContext(Dispatchers.IO) {
delete()
}
suspend fun File.deleteSuspend() =
withContext(Dispatchers.IO) {
delete()
}
suspend fun File.renameToSuspend(destination: File) = withContext(Dispatchers.IO) {
renameTo(destination)
}
suspend fun File.renameToSuspend(destination: File) =
withContext(Dispatchers.IO) {
renameTo(destination)
}
suspend fun File.listFilesSuspend() = withContext(Dispatchers.IO) {
listFiles()
}
suspend fun File.listFilesSuspend() =
withContext(Dispatchers.IO) {
listFiles()
}
/**
* 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.
*/
suspend fun File.writeAtomically(action: (suspend (File) -> Unit)) {
val tempFile = withContext(Dispatchers.IO) {
File(parentFile, name.newTempFileName()).also {
it.deleteOnExit()
val tempFile =
withContext(Dispatchers.IO) {
File(parentFile, name.newTempFileName()).also {
it.deleteOnExit()
}
}
}
var isWriteSuccessful = false
try {
action(tempFile)
isWriteSuccessful = true
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception
) {
tempFile.deleteSuspend()
throw e
} finally {

View File

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

View File

@ -10,8 +10,9 @@ import org.junit.Before
* 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.
*/
// Originally hoped to put this into ZcashUiTestRunner, although it causes reporting of test results to fail
open class UiTestPrerequisites {
// Originally hoped to put this into ZcashUiTestRunner, although it causes reporting of test results to fail
@Before
fun verifyPrerequisites() {
assertScreenIsOn()
@ -26,8 +27,9 @@ open class UiTestPrerequisites {
}
private fun isScreenOn(): Boolean {
val powerService = ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.POWER_SERVICE) as PowerManager
val powerService =
ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerService.isInteractive
}
@ -41,7 +43,7 @@ open class UiTestPrerequisites {
val keyguardService = (
ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
)
)
return keyguardService.isKeyguardLocked
}

View File

@ -12,8 +12,9 @@ class ZcashUiTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments)
val powerManager = ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.POWER_SERVICE) as PowerManager
val 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
// on won't work well for our tests.

View File

@ -2,4 +2,6 @@
root = true
[*.{kt,kts}]
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.
*/
class BasicStartupBenchmark {
companion object {
private const val APP_TARGET_PACKAGE_NAME = "co.electriccoin.zcash"
}
@ -27,13 +26,14 @@ class BasicStartupBenchmark {
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = APP_TARGET_PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
fun startup() =
benchmarkRule.measureRepeated(
packageName = APP_TARGET_PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}

View File

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

View File

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

View File

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

View File

@ -48,14 +48,16 @@ fun ChipGrid(
horizontalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier
.wrapContentWidth()
.clickable(
interactionSource = interactionSource,
indication = null, // Disable ripple
onClick = onGridClick
)
.testTag(CommonTag.CHIP_LAYOUT)
modifier =
Modifier
.wrapContentWidth()
.clickable(
interactionSource = interactionSource,
// Disable ripple
indication = null,
onClick = onGridClick
)
.testTag(CommonTag.CHIP_LAYOUT)
) {
wordList.chunked(CHIP_GRID_COLUMN_SIZE).forEachIndexed { chunkIndex, chunk ->
// 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
@Composable
fun GradientSurface(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
fun GradientSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Surface(
color = Color.Transparent,
modifier = modifier
.background(ZcashTheme.colors.surfaceGradient()),
modifier =
modifier
.background(ZcashTheme.colors.surfaceGradient()),
shape = RectangleShape,
content = content
)

View File

@ -15,7 +15,10 @@ import kotlinx.coroutines.flow.StateFlow
* for automated tests.
*/
@Composable
fun Override(configurationOverrideFlow: StateFlow<ConfigurationOverride?>, content: @Composable () -> Unit) {
fun Override(
configurationOverrideFlow: StateFlow<ConfigurationOverride?>,
content: @Composable () -> Unit
) {
val configurationOverride = configurationOverrideFlow.collectAsState().value
if (null == configurationOverride) {
@ -23,15 +26,16 @@ fun Override(configurationOverrideFlow: StateFlow<ConfigurationOverride?>, conte
} else {
val configuration = configurationOverride.newConfiguration(LocalConfiguration.current)
val contextWrapper = run {
val context = LocalContext.current
object : ContextThemeWrapper() {
init {
attachBaseContext(context)
applyOverrideConfiguration(configuration)
val contextWrapper =
run {
val context = LocalContext.current
object : ContextThemeWrapper() {
init {
attachBaseContext(context)
applyOverrideConfiguration(configuration)
}
}
}
}
CompositionLocalProvider(
LocalConfiguration provides configuration,
@ -43,15 +47,16 @@ fun Override(configurationOverrideFlow: StateFlow<ConfigurationOverride?>, conte
}
data class ConfigurationOverride(val uiMode: UiMode?, val locale: LocaleList?) {
fun newConfiguration(fromConfiguration: Configuration) = Configuration(fromConfiguration).apply {
this@ConfigurationOverride.uiMode?.let {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or it.flag()
}
fun newConfiguration(fromConfiguration: Configuration) =
Configuration(fromConfiguration).apply {
this@ConfigurationOverride.uiMode?.let {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or it.flag()
}
this@ConfigurationOverride.locale?.let {
setLocales(it)
this@ConfigurationOverride.locale?.let {
setLocales(it)
}
}
}
}
enum class UiMode {
@ -59,7 +64,8 @@ enum class UiMode {
Dark
}
private fun UiMode.flag() = when (this) {
UiMode.Light -> Configuration.UI_MODE_NIGHT_NO
UiMode.Dark -> Configuration.UI_MODE_NIGHT_YES
}
private fun UiMode.flag() =
when (this) {
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() }
ConstraintLayout(
modifier = modifier
.clickable(
interactionSource = interactionSource,
indication = null, // disable ripple
role = Role.Switch,
onClick = { onStateChange(!state) }
)
.fillMaxWidth()
modifier =
modifier
.clickable(
interactionSource = interactionSource,
// disable ripple
indication = null,
role = Role.Switch,
onClick = { onStateChange(!state) }
)
.fillMaxWidth()
) {
val (text, spacer, switchButton) = createRefs()
Body(
text = label,
modifier = Modifier.constrainAs(text) {
top.linkTo(parent.top)
bottom.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(spacer.start)
width = Dimension.fillToConstraints
}
)
Spacer(
modifier = Modifier
.width(ZcashTheme.dimens.spacingDefault)
.constrainAs(spacer) {
modifier =
Modifier.constrainAs(text) {
top.linkTo(parent.top)
bottom.linkTo(parent.top)
start.linkTo(text.end)
end.linkTo(switchButton.start)
start.linkTo(parent.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(
checked = state,
onCheckedChange = {
onStateChange(it)
},
modifier = Modifier.constrainAs(switchButton) {
top.linkTo(parent.top)
bottom.linkTo(parent.top)
start.linkTo(spacer.end)
end.linkTo(parent.end)
width = Dimension.wrapContent
}
modifier =
Modifier.constrainAs(switchButton) {
top.linkTo(parent.top)
bottom.linkTo(parent.top)
start.linkTo(spacer.end)
end.linkTo(parent.end)
width = Dimension.wrapContent
}
)
}
}

View File

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

View File

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

View File

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

View File

@ -18,30 +18,23 @@ data class Dimens(
val spacingLarge: Dp,
val spacingXlarge: Dp,
val spacingHuge: Dp,
// List of custom spacings:
// Button:
val buttonShadowOffsetX: Dp,
val buttonShadowOffsetY: Dp,
val buttonShadowSpread: Dp,
val buttonWidth: Dp,
val buttonHeight: Dp,
// Chip
val chipShadowElevation: Dp,
val chipStroke: Dp,
// TopAppBar:
val topAppBarZcashLogoHeight: Dp,
val topAppBarActionRippleCorner: Dp,
// TextField:
val textFieldDefaultHeight: Dp,
// Any Layout:
val layoutStroke: Dp,
// Screen custom spacings:
val inScreenZcashLogoHeight: Dp,
val inScreenZcashLogoWidth: Dp,
@ -49,35 +42,36 @@ data class Dimens(
val screenHorizontalSpacing: Dp,
)
private val defaultDimens = Dimens(
spacingNone = 0.dp,
spacingXtiny = 2.dp,
spacingTiny = 4.dp,
spacingSmall = 8.dp,
spacingDefault = 16.dp,
spacingLarge = 24.dp,
spacingXlarge = 32.dp,
spacingHuge = 64.dp,
buttonShadowOffsetX = 20.dp,
buttonShadowOffsetY = 20.dp,
buttonShadowSpread = 10.dp,
buttonWidth = 230.dp,
buttonHeight = 50.dp,
chipShadowElevation = 4.dp,
chipStroke = 0.5.dp,
topAppBarZcashLogoHeight = 24.dp,
topAppBarActionRippleCorner = 28.dp,
textFieldDefaultHeight = 215.dp,
layoutStroke = 1.dp,
inScreenZcashLogoHeight = 100.dp,
inScreenZcashLogoWidth = 60.dp,
inScreenZcashTextLogoHeight = 30.dp,
screenHorizontalSpacing = 64.dp,
)
private val defaultDimens =
Dimens(
spacingNone = 0.dp,
spacingXtiny = 2.dp,
spacingTiny = 4.dp,
spacingSmall = 8.dp,
spacingDefault = 16.dp,
spacingLarge = 24.dp,
spacingXlarge = 32.dp,
spacingHuge = 64.dp,
buttonShadowOffsetX = 20.dp,
buttonShadowOffsetY = 20.dp,
buttonShadowSpread = 10.dp,
buttonWidth = 230.dp,
buttonHeight = 50.dp,
chipShadowElevation = 4.dp,
chipStroke = 0.5.dp,
topAppBarZcashLogoHeight = 24.dp,
topAppBarActionRippleCorner = 28.dp,
textFieldDefaultHeight = 215.dp,
layoutStroke = 1.dp,
inScreenZcashLogoHeight = 100.dp,
inScreenZcashLogoWidth = 60.dp,
inScreenZcashTextLogoHeight = 30.dp,
screenHorizontalSpacing = 64.dp,
)
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
@ -119,7 +113,7 @@ internal var LocalDimens = staticCompositionLocalOf { defaultDimens }
* - rounded/normal screen shape
*/
@Composable
internal fun ProvideDimens(content: @Composable () -> Unit,) {
internal fun ProvideDimens(content: @Composable () -> Unit) {
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,
) {
@Composable
fun surfaceGradient() = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surface,
ZcashTheme.colors.surfaceEnd
fun surfaceGradient() =
Brush.verticalGradient(
colors =
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.
val useDarkMode = forceDarkMode || (BuildConfig.IS_APP_DARK_MODE_ENABLED && isSystemInDarkTheme())
val baseColors = if (useDarkMode) {
DarkColorPalette
} else {
LightColorPalette
}
val baseColors =
if (useDarkMode) {
DarkColorPalette
} else {
LightColorPalette
}
val extendedColors = if (useDarkMode) {
DarkExtendedColorPalette
} else {
LightExtendedColorPalette
}
val extendedColors =
if (useDarkMode) {
DarkExtendedColorPalette
} else {
LightExtendedColorPalette
}
CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
ProvideDimens {
@ -72,5 +74,5 @@ object ZcashTheme {
// TODO [#808]: https://github.com/Electric-Coin-Company/zashi-android/issues/808
val dimens: Dimens
@Composable
get() = LocalDimens.current
get() = localDimens.current
}

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