diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ebe5e0..9e03c9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,10 @@ android { compose = true } + testOptions { + unitTests.isReturnDefaultValues = true + } + lint { disable += setOf("ProtectedPermissions") abortOnError = false // Optional: prevents build failures due to other lint issues @@ -59,6 +63,8 @@ dependencies { implementation(libs.androidx.security.crypto) implementation(libs.androidx.appcompat) testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.kotlin.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/test/java/xyz/block/gosling/ExampleUnitTest.kt b/app/src/test/java/xyz/block/gosling/ExampleUnitTest.kt deleted file mode 100644 index c491a11..0000000 --- a/app/src/test/java/xyz/block/gosling/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package xyz.block.gosling - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/xyz/block/gosling/FakeAndroidKeyStore.kt b/app/src/test/java/xyz/block/gosling/FakeAndroidKeyStore.kt new file mode 100644 index 0000000..b39be1e --- /dev/null +++ b/app/src/test/java/xyz/block/gosling/FakeAndroidKeyStore.kt @@ -0,0 +1,100 @@ +package xyz.block.gosling + +import java.io.InputStream +import java.io.OutputStream +import java.security.Key +import java.security.KeyStore +import java.security.KeyStoreSpi +import java.security.Provider +import java.security.SecureRandom +import java.security.Security +import java.security.cert.Certificate +import java.security.spec.AlgorithmParameterSpec +import java.util.Date +import java.util.Enumeration +import javax.crypto.KeyGenerator +import javax.crypto.KeyGeneratorSpi +import javax.crypto.SecretKey + +/** + * The fake implementation of Android's KeyStore for Robolectric. + * + * See https://stackoverflow.com/questions/38213748/using-the-android-keystore-in-robolectric-tests. + */ +internal object FakeAndroidKeyStore { + private const val PROVIDER_NAME = "AndroidKeyStore" + + /** + * Initialize fake AndroidKeyStore for local testing. + */ + fun setUp() { + Security.addProvider(object : Provider(PROVIDER_NAME, 1.0, "") { + init { + put("KeyStore.AndroidKeyStore", FakeKeyStore::class.java.name) + put("KeyGenerator.AES", FakeAesKeyGenerator::class.java.name) + } + }) + } + + /** + * Remove fake AndroidKeyStore to clean up environment. + */ + fun tearDown() { + Security.removeProvider(PROVIDER_NAME) + } + + class FakeKeyStore : KeyStoreSpi() { + private val wrapped = KeyStore.getInstance(KeyStore.getDefaultType()) + + override fun engineIsKeyEntry(alias: String?): Boolean = + wrapped.isKeyEntry(alias) + override fun engineIsCertificateEntry(alias: String?): Boolean = + wrapped.isCertificateEntry(alias) + override fun engineGetCertificate(alias: String?): Certificate = + wrapped.getCertificate(alias) + override fun engineGetCreationDate(alias: String?): Date = + wrapped.getCreationDate(alias) + override fun engineDeleteEntry(alias: String?) = + wrapped.deleteEntry(alias) + override fun engineSetKeyEntry( + alias: String?, key: Key?, password: CharArray?, chain: Array?) = + wrapped.setKeyEntry(alias, key, password, chain) + + override fun engineSetKeyEntry( + alias: String?, key: ByteArray?, chain: Array?) = + wrapped.setKeyEntry(alias, key, chain) + override fun engineStore(stream: OutputStream?, password: CharArray?) = + wrapped.store(stream, password) + override fun engineSize(): Int = wrapped.size() + override fun engineAliases(): Enumeration = wrapped.aliases() + override fun engineContainsAlias(alias: String?): Boolean = + wrapped.containsAlias(alias) + override fun engineLoad(stream: InputStream?, password: CharArray?) = + wrapped.load(stream, password) + override fun engineGetCertificateChain(alias: String?): Array = + wrapped.getCertificateChain(alias) + override fun engineSetCertificateEntry(alias: String?, cert: Certificate?) = + wrapped.setCertificateEntry(alias, cert) + override fun engineGetCertificateAlias(cert: Certificate?): String = + wrapped.getCertificateAlias(cert) + override fun engineGetKey(alias: String?, password: CharArray?): Key { + var key: Key? = wrapped.getKey(alias, password) + if (key == null) { + // If key is null, generate one for it to make this fake method work. + engineSetKeyEntry(alias, KeyGenerator.getInstance("AES") + .generateKey(), password, null) + key = wrapped.getKey(alias, password) + } + return key!! + } + } + + class FakeAesKeyGenerator : KeyGeneratorSpi() { + private val wrapped = KeyGenerator.getInstance("AES") + + override fun engineInit(random: SecureRandom?) = Unit + override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) = Unit + override fun engineInit(keysize: Int, random: SecureRandom?) = Unit + override fun engineGenerateKey(): SecretKey = wrapped.generateKey() + } +} \ No newline at end of file diff --git a/app/src/test/java/xyz/block/gosling/GoslingApplicationTest.kt b/app/src/test/java/xyz/block/gosling/GoslingApplicationTest.kt new file mode 100644 index 0000000..1f0faef --- /dev/null +++ b/app/src/test/java/xyz/block/gosling/GoslingApplicationTest.kt @@ -0,0 +1,104 @@ +package xyz.block.gosling + +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import xyz.block.gosling.features.app.MainActivity +import xyz.block.gosling.features.launcher.LauncherActivity +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class GoslingApplicationTest { + @Before + fun setUp() { + FakeAndroidKeyStore.setUp() + } + + @After + fun tearDown() { + FakeAndroidKeyStore.tearDown() + } + + @Test + fun overlay_withoutAnyActivityRunning_shouldShow() { + assertFalse { GoslingApplication.shouldHideOverlay() } + } + + @Test + fun overlay_withMainActivityRunning_shouldHide() { + Robolectric.buildActivity(MainActivity::class.java).use { + it.create().start().resume() + assertTrue { GoslingApplication.shouldHideOverlay() } + } + } + + @Test + fun overlay_withLauncherActivityRunning_shouldHide() { + Robolectric.buildActivity(LauncherActivity::class.java).use { + it.create().start().resume() + assertTrue { GoslingApplication.shouldHideOverlay() } + } + } + + @Test + fun runningState_mainActivityResumed_mainActivityRunningIsTrue() { + Robolectric.buildActivity(MainActivity::class.java).use { + it.create().start().resume() + assertTrue { GoslingApplication.isMainActivityRunning } + } + } + + @Test + fun runningState_mainActivityNotResumed_mainActivityRunningIsFalse() { + Robolectric.buildActivity(MainActivity::class.java).use { + it.create() + assertFalse { GoslingApplication.isMainActivityRunning } + + it.start() + assertFalse { GoslingApplication.isMainActivityRunning } + + it.resume().pause() + assertFalse { GoslingApplication.isMainActivityRunning } + + it.stop() + assertFalse { GoslingApplication.isMainActivityRunning } + + it.destroy() + assertFalse { GoslingApplication.isMainActivityRunning } + } + } + + @Test + fun runningState_launcherActivityResumed_launcherActivityRunningIsTrue() { + Robolectric.buildActivity(LauncherActivity::class.java).use { + it.create().start().resume() + assertTrue { GoslingApplication.isLauncherActivityRunning } + } + } + + @Test + fun runningState_launcherActivityNotResumed_launcherActivityIsFalse() { + Robolectric.buildActivity(LauncherActivity::class.java).use { + it.create() + assertFalse { GoslingApplication.isLauncherActivityRunning } + + it.start() + assertFalse { GoslingApplication.isLauncherActivityRunning } + + it.resume().pause() + assertFalse { GoslingApplication.isLauncherActivityRunning } + + it.stop() + assertFalse { GoslingApplication.isLauncherActivityRunning } + + it.destroy() + assertFalse { GoslingApplication.isLauncherActivityRunning } + } + } +} \ No newline at end of file diff --git a/app/src/test/java/xyz/block/gosling/features/launcher/LauncherActivityTest.kt b/app/src/test/java/xyz/block/gosling/features/launcher/LauncherActivityTest.kt new file mode 100644 index 0000000..1dff474 --- /dev/null +++ b/app/src/test/java/xyz/block/gosling/features/launcher/LauncherActivityTest.kt @@ -0,0 +1,50 @@ +package xyz.block.gosling.features.launcher + +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.android.controller.ActivityController +import xyz.block.gosling.GoslingApplication +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class LauncherActivityTest { + private lateinit var launcherActivity: ActivityController + + @Before + fun setUp() { + launcherActivity = Robolectric.buildActivity(LauncherActivity::class.java) + } + + @After + fun tearDown() { + launcherActivity.close() + } + + @Test + fun lifecycle_runningState_succeed() { + assertFalse { GoslingApplication.isLauncherActivityRunning } + + launcherActivity.resume() + assertTrue { GoslingApplication.isLauncherActivityRunning } + + launcherActivity.pause() + assertFalse { GoslingApplication.isLauncherActivityRunning } + + launcherActivity.resume() + assertTrue { GoslingApplication.isLauncherActivityRunning } + + launcherActivity.pause() + launcherActivity.stop() + assertFalse { GoslingApplication.isLauncherActivityRunning } + + launcherActivity.destroy() + assertFalse { GoslingApplication.isLauncherActivityRunning } + } + + +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c97be96..c71a2e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlin = "2.0.0" coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" +robolectric = "4.16" espressoCore = "3.6.1" kotlinxSerializationJson = "1.6.3" lifecycleRuntimeKtx = "2.8.7" @@ -22,6 +23,8 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-savedstate = { module = "androidx.savedstate:savedstate", version.ref = "savedstate" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } junit = { group = "junit", name = "junit", version.ref = "junit" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }