diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 855f1189..d0a0dab4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(libs.androidx.annotation) coreLibraryDesugaring(libs.android.desugarJdkLibs) implementation(projects.autofillParser) + implementation(projects.coroutineUtils) implementation(projects.cryptoPgpainless) implementation(projects.formatCommon) implementation(projects.openpgpKtx) diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt new file mode 100644 index 00000000..bf84fc27 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.injection.coroutines + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.msfjarvis.aps.util.coroutines.DefaultDispatcherProvider +import dev.msfjarvis.aps.util.coroutines.DispatcherProvider + +@Module +@InstallIn(SingletonComponent::class) +interface DispatcherModule { + @Binds fun DefaultDispatcherProvider.bind(): DispatcherProvider +} diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt index e02a3b86..859559cd 100644 --- a/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt +++ b/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt @@ -15,5 +15,5 @@ import dev.msfjarvis.aps.util.totp.UriTotpFinder @Module @InstallIn(ActivityComponent::class) interface TotpModule { - @Binds abstract fun bindTotpFinder(totpFinder: UriTotpFinder): TotpFinder + @Binds fun UriTotpFinder.bind(): TotpFinder } diff --git a/coroutine-utils-testing/api/coroutine-utils-testing.api b/coroutine-utils-testing/api/coroutine-utils-testing.api new file mode 100644 index 00000000..a90e209f --- /dev/null +++ b/coroutine-utils-testing/api/coroutine-utils-testing.api @@ -0,0 +1,8 @@ +public final class dev/msfjarvis/aps/test/CoroutineTestRule : org/junit/rules/TestWatcher { + public fun ()V + public fun (Lkotlinx/coroutines/test/TestDispatcher;)V + public synthetic fun (Lkotlinx/coroutines/test/TestDispatcher;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getTestDispatcher ()Lkotlinx/coroutines/test/TestDispatcher; + public final fun getTestDispatcherProvider ()Ldev/msfjarvis/aps/util/coroutines/DispatcherProvider; +} + diff --git a/coroutine-utils-testing/build.gradle.kts b/coroutine-utils-testing/build.gradle.kts new file mode 100644 index 00000000..96d87dc1 --- /dev/null +++ b/coroutine-utils-testing/build.gradle.kts @@ -0,0 +1,14 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +plugins { + kotlin("jvm") + id("com.github.android-password-store.kotlin-library") +} + +dependencies { + implementation(projects.coroutineUtils) + implementation(libs.testing.junit) + implementation(libs.kotlin.coroutines.test) +} diff --git a/coroutine-utils-testing/src/main/kotlin/dev/msfjarvis/aps/test/CoroutineTestRule.kt b/coroutine-utils-testing/src/main/kotlin/dev/msfjarvis/aps/test/CoroutineTestRule.kt new file mode 100644 index 00000000..fa4a2d41 --- /dev/null +++ b/coroutine-utils-testing/src/main/kotlin/dev/msfjarvis/aps/test/CoroutineTestRule.kt @@ -0,0 +1,45 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.test + +import dev.msfjarvis.aps.util.coroutines.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * JUnit [TestWatcher] to correctly handle setting and resetting a given [testDispatcher] for tests. + */ +@ExperimentalCoroutinesApi +public class CoroutineTestRule( + public val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()), +) : TestWatcher() { + + public val testDispatcherProvider: DispatcherProvider = + object : DispatcherProvider { + override fun default(): CoroutineDispatcher = testDispatcher + override fun io(): CoroutineDispatcher = testDispatcher + override fun main(): CoroutineDispatcher = testDispatcher + override fun unconfined(): CoroutineDispatcher = testDispatcher + } + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/coroutine-utils/api/coroutine-utils.api b/coroutine-utils/api/coroutine-utils.api new file mode 100644 index 00000000..000dde65 --- /dev/null +++ b/coroutine-utils/api/coroutine-utils.api @@ -0,0 +1,22 @@ +public final class dev/msfjarvis/aps/util/coroutines/DefaultDispatcherProvider : dev/msfjarvis/aps/util/coroutines/DispatcherProvider { + public fun ()V + public fun default ()Lkotlinx/coroutines/CoroutineDispatcher; + public fun io ()Lkotlinx/coroutines/CoroutineDispatcher; + public fun main ()Lkotlinx/coroutines/CoroutineDispatcher; + public fun unconfined ()Lkotlinx/coroutines/CoroutineDispatcher; +} + +public abstract interface class dev/msfjarvis/aps/util/coroutines/DispatcherProvider { + public abstract fun default ()Lkotlinx/coroutines/CoroutineDispatcher; + public abstract fun io ()Lkotlinx/coroutines/CoroutineDispatcher; + public abstract fun main ()Lkotlinx/coroutines/CoroutineDispatcher; + public abstract fun unconfined ()Lkotlinx/coroutines/CoroutineDispatcher; +} + +public final class dev/msfjarvis/aps/util/coroutines/DispatcherProvider$DefaultImpls { + public static fun default (Ldev/msfjarvis/aps/util/coroutines/DispatcherProvider;)Lkotlinx/coroutines/CoroutineDispatcher; + public static fun io (Ldev/msfjarvis/aps/util/coroutines/DispatcherProvider;)Lkotlinx/coroutines/CoroutineDispatcher; + public static fun main (Ldev/msfjarvis/aps/util/coroutines/DispatcherProvider;)Lkotlinx/coroutines/CoroutineDispatcher; + public static fun unconfined (Ldev/msfjarvis/aps/util/coroutines/DispatcherProvider;)Lkotlinx/coroutines/CoroutineDispatcher; +} + diff --git a/coroutine-utils/build.gradle.kts b/coroutine-utils/build.gradle.kts new file mode 100644 index 00000000..2a20df08 --- /dev/null +++ b/coroutine-utils/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +plugins { + kotlin("jvm") + id("com.github.android-password-store.kotlin-library") +} + +dependencies { + implementation(libs.kotlin.coroutines.core) + implementation(libs.dagger.hilt.core) +} diff --git a/coroutine-utils/src/main/kotlin/dev/msfjarvis/aps/util/coroutines/DispatcherProvider.kt b/coroutine-utils/src/main/kotlin/dev/msfjarvis/aps/util/coroutines/DispatcherProvider.kt new file mode 100644 index 00000000..a7c4530d --- /dev/null +++ b/coroutine-utils/src/main/kotlin/dev/msfjarvis/aps/util/coroutines/DispatcherProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.coroutines + +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** Interface to allow abstracting individual [CoroutineDispatcher]s out of class dependencies. */ +public interface DispatcherProvider { + + public fun main(): CoroutineDispatcher = Dispatchers.Main + public fun default(): CoroutineDispatcher = Dispatchers.Default + public fun io(): CoroutineDispatcher = Dispatchers.IO + public fun unconfined(): CoroutineDispatcher = Dispatchers.Unconfined +} + +/** Concrete type for [DispatcherProvider] with all the defaults from the class. */ +public class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider diff --git a/format-common/api/format-common.api b/format-common/api/format-common.api index d0caaf78..629644e3 100644 --- a/format-common/api/format-common.api +++ b/format-common/api/format-common.api @@ -1,5 +1,5 @@ public final class dev/msfjarvis/aps/data/passfile/PasswordEntry { - public fun (Ldev/msfjarvis/aps/util/time/UserClock;Ldev/msfjarvis/aps/util/totp/TotpFinder;Lkotlinx/coroutines/CoroutineScope;[B)V + public fun (Ldev/msfjarvis/aps/util/time/UserClock;Ldev/msfjarvis/aps/util/totp/TotpFinder;Ldev/msfjarvis/aps/util/coroutines/DispatcherProvider;Lkotlinx/coroutines/CoroutineScope;[B)V public final fun getExtraContent ()Ljava/util/Map; public final fun getExtraContentString ()Ljava/lang/String; public final fun getExtraContentWithoutAuthData ()Ljava/lang/String; diff --git a/format-common/build.gradle.kts b/format-common/build.gradle.kts index e5400bcc..3b940573 100644 --- a/format-common/build.gradle.kts +++ b/format-common/build.gradle.kts @@ -9,11 +9,13 @@ plugins { } dependencies { + implementation(projects.coroutineUtils) implementation(libs.androidx.annotation) implementation(libs.dagger.hilt.core) implementation(libs.thirdparty.commons.codec) implementation(libs.thirdparty.kotlinResult) implementation(libs.kotlin.coroutines.core) + testImplementation(projects.coroutineUtilsTesting) testImplementation(libs.bundles.testDependencies) testImplementation(libs.kotlin.coroutines.test) } diff --git a/format-common/src/main/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntry.kt b/format-common/src/main/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntry.kt index 408069a2..81398b45 100644 --- a/format-common/src/main/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntry.kt +++ b/format-common/src/main/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntry.kt @@ -9,6 +9,7 @@ import com.github.michaelbull.result.mapBoth import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import dev.msfjarvis.aps.util.coroutines.DispatcherProvider import dev.msfjarvis.aps.util.time.UserClock import dev.msfjarvis.aps.util.totp.Otp import dev.msfjarvis.aps.util.totp.TotpFinder @@ -32,6 +33,8 @@ constructor( clock: UserClock, /** [TotpFinder] implementation to extract data from a TOTP URI */ totpFinder: TotpFinder, + /** Instance of [DispatcherProvider] to select an IO dispatcher for emitting TOTP values. */ + dispatcherProvider: DispatcherProvider, /** * A cancellable [CoroutineScope] inside which we constantly emit new TOTP values as time elapses */ @@ -81,7 +84,7 @@ constructor( username = findUsername() totpSecret = totpFinder.findSecret(content) if (totpSecret != null) { - scope.launch { + scope.launch(dispatcherProvider.io()) { val digits = totpFinder.findDigits(content) val totpPeriod = totpFinder.findPeriod(content) val totpAlgorithm = totpFinder.findAlgorithm(content) diff --git a/format-common/src/test/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntryTest.kt b/format-common/src/test/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntryTest.kt index 2923946e..32066cc3 100644 --- a/format-common/src/test/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntryTest.kt +++ b/format-common/src/test/kotlin/dev/msfjarvis/aps/data/passfile/PasswordEntryTest.kt @@ -5,6 +5,7 @@ package dev.msfjarvis.aps.data.passfile +import dev.msfjarvis.aps.test.CoroutineTestRule import dev.msfjarvis.aps.util.time.TestUserClock import dev.msfjarvis.aps.util.totp.TotpFinder import java.util.Locale @@ -16,15 +17,22 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.junit.Rule @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class PasswordEntryTest { + @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private fun makeEntry(content: String) = - PasswordEntry(fakeClock, testFinder, scope, content.encodeToByteArray()) + PasswordEntry( + fakeClock, + testFinder, + coroutineTestRule.testDispatcherProvider, + TestScope(coroutineTestRule.testDispatcher), + content.encodeToByteArray(), + ) @Test fun testGetPassword() { @@ -125,19 +133,20 @@ class PasswordEntryTest { @Test @Ignore("Timing with runTest seems hard to implement right now") - fun testGeneratesOtpFromTotpUri() = - scope.runTest { + fun testGeneratesOtpFromTotpUri() { + runTest { val entry = makeEntry("secret\nextra\n$TOTP_URI") assertTrue(entry.hasTotp()) val code = entry.totp.value assertNotNull(code) { "Generated OTP cannot be null" } assertEquals("818800", code) } + } @Test @Ignore("Timing with runTest seems hard to implement right now") - fun testGeneratesOtpWithOnlyUriInFile() = - scope.runTest { + fun testGeneratesOtpWithOnlyUriInFile() { + runTest { val entry = makeEntry(TOTP_URI) assertNull(entry.password) assertTrue(entry.hasTotp()) @@ -145,6 +154,7 @@ class PasswordEntryTest { assertNotNull(code) { "Generated OTP cannot be null" } assertEquals("818800", code) } + } @Test fun testOnlyLooksForUriInFirstLine() { @@ -171,9 +181,6 @@ class PasswordEntryTest { const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30" - val dispatcher = StandardTestDispatcher() - val scope = TestScope(dispatcher) - val fakeClock = TestUserClock() // This implementation is hardcoded for the URI above. diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c600dc7..9ea2cd3b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,10 @@ include("app") include("autofill-parser") +include("coroutine-utils") + +include("coroutine-utils-testing") + include("crypto-common") include("crypto-pgpainless") @@ -50,4 +54,4 @@ include("format-common") include("openpgp-ktx") -include(":dependency-sync") +include("dependency-sync")