diff --git a/formula-android-tests/build.gradle.kts b/formula-android-tests/build.gradle.kts index 5769be9b7..ce0fe99c0 100644 --- a/formula-android-tests/build.gradle.kts +++ b/formula-android-tests/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") + id("com.android.library") id("kotlin-android") id("kotlin-parcelize") } @@ -10,19 +10,6 @@ apply { android { namespace = "com.instacart.formula" - defaultConfig { - applicationId = "com.instacart.formula" - versionCode = 1 - versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } testOptions { unitTests { @@ -47,4 +34,5 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.robolectric) testImplementation(libs.truth) + testImplementation(project(":test-utils:android")) } diff --git a/formula-android-tests/proguard-rules.pro b/formula-android-tests/proguard-rules.pro deleted file mode 100644 index f1b424510..000000000 --- a/formula-android-tests/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile diff --git a/formula-android-tests/src/main/AndroidManifest.xml b/formula-android-tests/src/main/AndroidManifest.xml index 3ae9bb658..c48ff6712 100644 --- a/formula-android-tests/src/main/AndroidManifest.xml +++ b/formula-android-tests/src/main/AndroidManifest.xml @@ -34,19 +34,5 @@ - - - - - - - - - - - - - - diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt index decae86ff..aa313f125 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt @@ -1,10 +1,10 @@ package com.instacart.formula import android.app.Activity -import androidx.fragment.app.FragmentActivity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instacart.testutils.android.TestActivity import org.junit.Before import org.junit.Rule import org.junit.Test @@ -16,7 +16,6 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class NonBoundActivityTest { - class TestActivity : Activity() private val formulaRule = TestFormulaRule( initFormula = { app -> diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt index 56144c143..a48fadca7 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt @@ -1,9 +1,9 @@ package com.instacart.formula -import androidx.fragment.app.FragmentActivity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instacart.testutils.android.TestFragmentActivity import org.junit.Before import org.junit.Rule import org.junit.Test @@ -11,23 +11,21 @@ import org.junit.rules.RuleChain import org.junit.runner.RunWith /** - * Tests that formula-android module handles non-bound activities gracefully. + * Tests that formula-android module handles non-bound fragment activities gracefully. */ @RunWith(AndroidJUnit4::class) class NonBoundFragmentActivityTest { - class TestActivity : FragmentActivity() - private val formulaRule = TestFormulaRule( initFormula = { app -> FormulaAndroid.init(app) {} } ) - private val activityRule = ActivityScenarioRule(TestActivity::class.java) + private val activityRule = ActivityScenarioRule(TestFragmentActivity::class.java) @get:Rule val rule = RuleChain.outerRule(formulaRule).around(activityRule) - lateinit var scenario: ActivityScenario + lateinit var scenario: ActivityScenario @Before fun setup() { diff --git a/formula-android/build.gradle.kts b/formula-android/build.gradle.kts index dddf15a75..da2e21046 100644 --- a/formula-android/build.gradle.kts +++ b/formula-android/build.gradle.kts @@ -16,6 +16,7 @@ android { testOptions { unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true } publishing { @@ -40,5 +41,6 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) testImplementation(libs.truth) + testImplementation(project(":test-utils:android")) } diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreContextImpl.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreContextImpl.kt index daf80e332..4adc99429 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreContextImpl.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreContextImpl.kt @@ -59,7 +59,7 @@ internal class ActivityStoreContextImpl : ActivityS select: Activity.() -> Observable ): Observable { // TODO: should probably use startedActivity - return activityAttachEvents() + return attachEventRelay .switchMap { val activity = activity if (activity == null) { @@ -105,8 +105,8 @@ internal class ActivityStoreContextImpl : ActivityS fun detachActivity(activity: Activity) { if (this.activity == activity) { this.activity = null + attachEventRelay.accept(false) } - attachEventRelay.accept(false) } fun updateFragmentLifecycleState(id: FragmentId, newState: Lifecycle.State) { @@ -121,8 +121,6 @@ internal class ActivityStoreContextImpl : ActivityS fragmentStateUpdated.accept(contract.tag) } - private fun activityAttachEvents(): Observable = attachEventRelay - private fun fragmentLifecycleState(tag: String): Observable { return fragmentStateUpdated .filter { it == tag } diff --git a/formula-android/src/test/java/com/instacart/formula/android/internal/ActivityStoreContextTest.kt b/formula-android/src/test/java/com/instacart/formula/android/internal/ActivityStoreContextTest.kt new file mode 100644 index 000000000..3bb96c449 --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/internal/ActivityStoreContextTest.kt @@ -0,0 +1,92 @@ +package com.instacart.formula.android.internal + +import android.os.Looper +import androidx.fragment.app.FragmentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.instacart.testutils.android.TestFragmentActivity +import com.instacart.testutils.android.activity +import com.instacart.testutils.android.executeOnBackgroundThread +import com.instacart.testutils.android.throwOnTimeout +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch + +@RunWith(AndroidJUnit4::class) +class ActivityStoreContextTest { + + @Test fun `started activity returns null until onActivityStarted is called`() { + val scenario = ActivityScenario.launch(TestFragmentActivity::class.java) + val storeContext = ActivityStoreContextImpl() + + // Initially null + assertThat(storeContext.startedActivity()).isNull() + + // After attach + storeContext.attachActivity(scenario.activity()) + assertThat(storeContext.startedActivity()).isNull() + + // After on started + storeContext.onActivityStarted(scenario.activity()) + assertThat(storeContext.startedActivity()).isEqualTo(scenario.activity()) + } + + @Test fun `detaches only if the activity matches`() { + val scenario = ActivityScenario.launch(TestFragmentActivity::class.java) + val storeContext = ActivityStoreContextImpl() + + val oldActivity = scenario.activity() + val newActivity = scenario.recreate().activity() + + storeContext.attachActivity(newActivity) + storeContext.onActivityStarted(newActivity) + storeContext.detachActivity(oldActivity) + + assertThat(storeContext.startedActivity()).isEqualTo(newActivity) + } + + @Test fun `send posts events on the main thread`() { + val scenario = ActivityScenario.launch(TestFragmentActivity::class.java) + val storeContext = ActivityStoreContextImpl() + storeContext.attachActivity(scenario.activity()) + storeContext.onActivityStarted(scenario.activity()) + + val effectThread = mutableListOf() + storeContext.send { effectThread.add(Looper.myLooper()) } + storeContext.sendOnBackgroundThread { effectThread.add(Looper.myLooper()) } + + assertThat(effectThread).containsExactly( + Looper.getMainLooper(), Looper.getMainLooper() + ) + } + + @Test + fun `send drops the action if there is no started activity`() { + val storeContext = ActivityStoreContextImpl() + + val effectThread = mutableListOf() + storeContext.send { effectThread.add(Looper.myLooper()) } + + val result = runCatching { + storeContext.sendOnBackgroundThread { effectThread.add(Looper.myLooper()) } + } + assertThat(effectThread).isEmpty() + assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "timeout" + ) + } + + private fun ActivityStoreContextImpl.sendOnBackgroundThread( + action: ActivityType.() -> Unit + ) { + val sendLatch = CountDownLatch(1) + executeOnBackgroundThread { + send { + action() + sendLatch.countDown() + } + } + sendLatch.throwOnTimeout() + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ceade7280..d7e1ae7c7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,6 @@ include( ":samples:stopwatch-compose", ":samples:todoapp" ) +include( + ":test-utils:android" +) diff --git a/test-utils/android/.gitignore b/test-utils/android/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/test-utils/android/.gitignore @@ -0,0 +1 @@ +/build diff --git a/test-utils/android/build.gradle.kts b/test-utils/android/build.gradle.kts new file mode 100644 index 000000000..0d59a49ad --- /dev/null +++ b/test-utils/android/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("kotlin-parcelize") +} + +android { + namespace = "com.instacart.testutils.android" +} + +dependencies { + implementation(project(":formula-rxjava3")) + implementation(project(":formula-android")) + + implementation(libs.kotlin) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.test.core.ktx) + implementation(libs.lifecycle.extensions) + implementation(libs.robolectric) +} diff --git a/test-utils/android/src/main/AndroidManifest.xml b/test-utils/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d9fd94f38 --- /dev/null +++ b/test-utils/android/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/test-utils/android/src/main/java/com/instacart/testutils/android/TestActivity.kt b/test-utils/android/src/main/java/com/instacart/testutils/android/TestActivity.kt new file mode 100644 index 000000000..c3f21cc1b --- /dev/null +++ b/test-utils/android/src/main/java/com/instacart/testutils/android/TestActivity.kt @@ -0,0 +1,5 @@ +package com.instacart.testutils.android + +import android.app.Activity + +class TestActivity : Activity() \ No newline at end of file diff --git a/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt b/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt new file mode 100644 index 000000000..759a982c2 --- /dev/null +++ b/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt @@ -0,0 +1,37 @@ +package com.instacart.testutils.android + +import android.app.Activity +import android.os.Looper +import androidx.test.core.app.ActivityScenario +import org.robolectric.Shadows +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +fun ActivityScenario.activity(): A { + return get { this } +} + +fun ActivityScenario.get(select: A.() -> T): T { + val list: MutableList = mutableListOf() + onActivity { + list.add(it.select()) + } + return list.first() +} + +fun CountDownLatch.throwOnTimeout() { + if (!await(100, TimeUnit.MILLISECONDS)) { + throw IllegalStateException("timeout") + } +} + +fun executeOnBackgroundThread(action: () -> Unit) { + val initLatch = CountDownLatch(1) + Executors.newSingleThreadExecutor().execute { + action() + initLatch.countDown() + } + initLatch.throwOnTimeout() + Shadows.shadowOf(Looper.getMainLooper()).idle() +} diff --git a/test-utils/android/src/main/java/com/instacart/testutils/android/TestFragmentActivity.kt b/test-utils/android/src/main/java/com/instacart/testutils/android/TestFragmentActivity.kt new file mode 100644 index 000000000..2da176eda --- /dev/null +++ b/test-utils/android/src/main/java/com/instacart/testutils/android/TestFragmentActivity.kt @@ -0,0 +1,5 @@ +package com.instacart.testutils.android + +import androidx.fragment.app.FragmentActivity + +class TestFragmentActivity : FragmentActivity() \ No newline at end of file