From cc62fbc07de2a33434ee9befa7bc9a93cf35314f Mon Sep 17 00:00:00 2001 From: emmano Date: Fri, 10 Sep 2021 21:01:32 -0500 Subject: [PATCH 1/6] coroutine-flow-support: Add support to create a Flow from formula. (EO) --- dependencies.gradle | 7 +- .../instacart/formula/android/FeatureView.kt | 3 +- formula-coroutines/.gitignore | 1 + formula-coroutines/build.gradle | 20 +++++ .../formula/coroutines/FlowRuntime.kt | 38 +++++++++ .../coroutines/FlowRuntimeExtensions.kt | 22 +++++ .../formula/coroutines/FlowStream.kt | 79 ++++++++++++++++++ samples/counter-coroutines/.gitignore | 1 + samples/counter-coroutines/build.gradle | 56 +++++++++++++ samples/counter-coroutines/proguard-rules.pro | 21 +++++ .../src/main/AndroidManifest.xml | 18 ++++ .../formula/counter/CounterActivity.kt | 32 +++++++ .../formula/counter/CounterFormula.kt | 28 +++++++ .../formula/counter/CounterRenderModel.kt | 7 ++ .../formula/counter/CounterRenderView.kt | 23 +++++ .../drawable-v24/ic_launcher_foreground.xml | 34 ++++++++ .../res/drawable/ic_launcher_background.xml | 74 ++++++++++++++++ .../src/main/res/layout/counter_activity.xml | 35 ++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 ++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2963 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4905 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2060 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2783 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4490 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6895 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6387 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10413 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9128 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15132 bytes .../src/main/res/values/colors.xml | 6 ++ .../src/main/res/values/strings.xml | 3 + .../src/main/res/values/styles.xml | 11 +++ .../formula/counter/CounterFormulaTest.kt | 22 +++++ settings.gradle | 2 + 35 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 formula-coroutines/.gitignore create mode 100644 formula-coroutines/build.gradle create mode 100644 formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt create mode 100644 formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntimeExtensions.kt create mode 100644 formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowStream.kt create mode 100644 samples/counter-coroutines/.gitignore create mode 100644 samples/counter-coroutines/build.gradle create mode 100644 samples/counter-coroutines/proguard-rules.pro create mode 100644 samples/counter-coroutines/src/main/AndroidManifest.xml create mode 100644 samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterActivity.kt create mode 100644 samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterFormula.kt create mode 100644 samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderModel.kt create mode 100644 samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderView.kt create mode 100644 samples/counter-coroutines/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 samples/counter-coroutines/src/main/res/drawable/ic_launcher_background.xml create mode 100644 samples/counter-coroutines/src/main/res/layout/counter_activity.xml create mode 100644 samples/counter-coroutines/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 samples/counter-coroutines/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 samples/counter-coroutines/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 samples/counter-coroutines/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 samples/counter-coroutines/src/main/res/values/colors.xml create mode 100644 samples/counter-coroutines/src/main/res/values/strings.xml create mode 100644 samples/counter-coroutines/src/main/res/values/styles.xml create mode 100644 samples/counter-coroutines/src/test/java/com/instacart/formula/counter/CounterFormulaTest.kt diff --git a/dependencies.gradle b/dependencies.gradle index 74e0407d9..ca1ff27da 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -3,6 +3,7 @@ ext { androidTestVersion = "1.4.0" androidJUnitVersion = "1.1.0" lifecycleVersion = '2.2.0' + lifecycleKtxVersion = '2.4.0-alpha03' espressoVersion = "3.4.0" junitVersion = "4.13.2" @@ -12,6 +13,7 @@ ext { robolectricVersion = "4.6.1" truthVersion = "1.1.3" composeVersion = "1.0.1" + kotlinCoroutinesVersion = "1.5.2" libraries = [ androidx : [ @@ -38,7 +40,9 @@ ext { constraintlayout: "androidx.constraintlayout:constraintlayout:2.1.0", lifecycle : [ runtime : "androidx.lifecycle:lifecycle-runtime:$lifecycleVersion", - extensions: "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" + extensions: "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion", + runtimektx : "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleKtxVersion", + extensionsktx: "androidx.lifecycle:lifecycle-extensions-ktx:$lifecycleKtxVersion", ], recyclerview : "androidx.recyclerview:recyclerview:$androidXVersion", fragment : [ @@ -60,6 +64,7 @@ ext { ], junit : "junit:junit:$junitVersion", kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion", + coroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion", kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion", robolectric : "org.robolectric:robolectric:$robolectricVersion", rxjava : "io.reactivex.rxjava3:rxjava:3.1.1", diff --git a/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt b/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt index e491b2a13..fdd83152d 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt @@ -1,6 +1,7 @@ package com.instacart.formula.android import android.view.View +import androidx.lifecycle.LifecycleObserver import com.instacart.formula.Cancelable import io.reactivex.rxjava3.core.Observable @@ -14,7 +15,7 @@ import io.reactivex.rxjava3.core.Observable * * @param view The root Android view. * @param bind A bind function connects state observable to the view rendering. - * @param lifecycleCallbacks Optional lifecycle callbacks if you need to know the Fragment state. + * @param lifecycleObserver Optional lifecycle callbacks if you need to know the Fragment state. */ class FeatureView( val view: View, diff --git a/formula-coroutines/.gitignore b/formula-coroutines/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/formula-coroutines/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/formula-coroutines/build.gradle b/formula-coroutines/build.gradle new file mode 100644 index 000000000..8c18ef51d --- /dev/null +++ b/formula-coroutines/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation libraries.kotlin + implementation libraries.coroutines + + api project(":formula") + + testImplementation project(":formula-test") + testImplementation libraries.truth + testImplementation libraries.junit +} \ No newline at end of file diff --git a/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt new file mode 100644 index 000000000..57a27910c --- /dev/null +++ b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt @@ -0,0 +1,38 @@ +package com.instacart.formula.coroutines + +import com.instacart.formula.FormulaRuntime +import com.instacart.formula.IFormula +import com.instacart.formula.internal.ThreadChecker +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.* + +object FlowRuntime { + fun start( + input: Flow, + formula: IFormula + ): Flow { + val threadChecker = ThreadChecker() + return callbackFlow { + threadChecker.check("Need to subscribe on main thread.") + + + var runtime = FormulaRuntime(threadChecker, formula, ::trySendBlocking, channel::close) + + input.collect { input -> + threadChecker.check("Input arrived on a wrong thread.") + if (!runtime.isKeyValid(input)) { + runtime.terminate() + runtime = + FormulaRuntime(threadChecker, formula, ::trySendBlocking, channel::close) + } + runtime.onInput(input) + } + + awaitClose { + runtime.terminate() + } + + }.distinctUntilChanged() + } +} \ No newline at end of file diff --git a/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntimeExtensions.kt b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntimeExtensions.kt new file mode 100644 index 000000000..a619bfe1f --- /dev/null +++ b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntimeExtensions.kt @@ -0,0 +1,22 @@ +package com.instacart.formula.coroutines + +import com.instacart.formula.IFormula +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + + +fun IFormula.toFlow(): Flow { + return toFlow(input = Unit) +} + +fun IFormula.toFlow( + input: Input +): Flow { + return toFlow(input = flowOf(input)) +} + +fun IFormula.toFlow( + input: Flow +): Flow { + return FlowRuntime.start(input = input, formula = this) +} diff --git a/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowStream.kt b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowStream.kt new file mode 100644 index 000000000..be90c134b --- /dev/null +++ b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowStream.kt @@ -0,0 +1,79 @@ +package com.instacart.formula.coroutines + +import com.instacart.formula.Cancelable +import com.instacart.formula.Stream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.runBlocking + +/** + * Formula [Stream] adapter to enable coroutine's Flow use. + */ +interface FlowStream : Stream { + + companion object { + /** + * Creates a [Stream] from a [Flow] factory [create]. + * + * ``` + * events(FlowStream.fromFlow { locationManager.updates() }) { event -> + * transition() + * } + * ``` + */ + inline fun fromFlow( + scope: CoroutineScope, + crossinline create: () -> Flow + ): Stream { + return object : FlowStream { + + override val scope: CoroutineScope = scope + + override fun flow(): Flow { + return create() + } + + override fun key(): Any = Unit + } + } + + /** + * Creates a [Stream] from a [Flow] factory [create]. + * + * ``` + * events(FlowStream.fromFlow(itemId) { repo.fetchItem(itemId) }) { event -> + * transition() + * } + * ``` + * + * @param key Used to distinguish this [Stream] from other streams. + */ + inline fun fromFlow( + scope: CoroutineScope, + key: Any?, + crossinline create: () -> Flow + ): Stream { + return object : FlowStream { + override val scope: CoroutineScope = scope + + override fun flow(): Flow { + return create() + } + + override fun key(): Any? = key + } + } + } + + fun flow(): Flow + + val scope: CoroutineScope + + override fun start(send: (Message) -> Unit): Cancelable? { + val job = flow().launchIn(scope) + return Cancelable(job::cancel) + } +} \ No newline at end of file diff --git a/samples/counter-coroutines/.gitignore b/samples/counter-coroutines/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/samples/counter-coroutines/.gitignore @@ -0,0 +1 @@ +/build diff --git a/samples/counter-coroutines/build.gradle b/samples/counter-coroutines/build.gradle new file mode 100644 index 000000000..2ea4b76ab --- /dev/null +++ b/samples/counter-coroutines/build.gradle @@ -0,0 +1,56 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: "kotlin-parcelize" + +android { + compileSdkVersion constants.compileSdk + defaultConfig { + applicationId "com.instacart.formula.counter.coroutines" + minSdkVersion constants.minSdk + targetSdkVersion constants.targetSdk + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation project(":formula-coroutines") + + implementation libraries.kotlin + implementation libraries.androidx.appcompat + implementation libraries.androidx.lifecycle.runtimektx + + testImplementation libraries.junit + testImplementation "com.google.truth:truth:$truthVersion" + + testImplementation libraries.androidx.test.rules + testImplementation libraries.androidx.test.runner + testImplementation libraries.androidx.test.espresso.core + testImplementation libraries.robolectric + testImplementation project(":formula-test") +} diff --git a/samples/counter-coroutines/proguard-rules.pro b/samples/counter-coroutines/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/samples/counter-coroutines/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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/samples/counter-coroutines/src/main/AndroidManifest.xml b/samples/counter-coroutines/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1793d76ff --- /dev/null +++ b/samples/counter-coroutines/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterActivity.kt b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterActivity.kt new file mode 100644 index 000000000..5b2c5960c --- /dev/null +++ b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterActivity.kt @@ -0,0 +1,32 @@ +package com.instacart.formula.counter + +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.instacart.formula.coroutines.toFlow +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class CounterActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.counter_activity) + + val renderView = CounterRenderView(findViewById(R.id.activity_content)) + + val formula = CounterFormula() + + formula.toFlow().safeCollect { renderView.render(it) } + } + + fun Flow.safeCollect(block: (T) -> Unit) = lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + this@safeCollect.collect { + block(it) + } + } + } +} diff --git a/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterFormula.kt b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterFormula.kt new file mode 100644 index 000000000..9c2d45582 --- /dev/null +++ b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterFormula.kt @@ -0,0 +1,28 @@ +package com.instacart.formula.counter + +import com.instacart.formula.Evaluation +import com.instacart.formula.Formula +import com.instacart.formula.FormulaContext + +class CounterFormula : Formula { + + override fun initialState(input: Unit): Int = 0 + + override fun evaluate( + input: Unit, + state: Int, + context: FormulaContext + ): Evaluation { + return Evaluation( + output = CounterRenderModel( + count = "Count: $state", + onDecrement = context.callback { + transition(state - 1) + }, + onIncrement = context.callback { + transition(state + 1) + } + ) + ) + } +} diff --git a/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderModel.kt b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderModel.kt new file mode 100644 index 000000000..3e2754fc6 --- /dev/null +++ b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderModel.kt @@ -0,0 +1,7 @@ +package com.instacart.formula.counter + +data class CounterRenderModel( + val count: String, + val onDecrement: () -> Unit, + val onIncrement: () -> Unit +) diff --git a/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderView.kt b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderView.kt new file mode 100644 index 000000000..1e2f21ecf --- /dev/null +++ b/samples/counter-coroutines/src/main/java/com/instacart/formula/counter/CounterRenderView.kt @@ -0,0 +1,23 @@ +package com.instacart.formula.counter + +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import com.instacart.formula.Renderer +import com.instacart.formula.RenderView + +class CounterRenderView(private val root: ViewGroup): RenderView { + private val decrementButton: Button = root.findViewById(R.id.decrement_button) + private val incrementButton: Button = root.findViewById(R.id.increment_button) + private val countTextView: TextView = root.findViewById(R.id.count_text_view) + + override val render: Renderer = Renderer { model -> + countTextView.text = model.count + decrementButton.setOnClickListener { + model.onDecrement() + } + incrementButton.setOnClickListener { + model.onIncrement() + } + } +} diff --git a/samples/counter-coroutines/src/main/res/drawable-v24/ic_launcher_foreground.xml b/samples/counter-coroutines/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..c7bd21dbd --- /dev/null +++ b/samples/counter-coroutines/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/samples/counter-coroutines/src/main/res/drawable/ic_launcher_background.xml b/samples/counter-coroutines/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..2408e30d1 --- /dev/null +++ b/samples/counter-coroutines/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/counter-coroutines/src/main/res/layout/counter_activity.xml b/samples/counter-coroutines/src/main/res/layout/counter_activity.xml new file mode 100644 index 000000000..caffcb41f --- /dev/null +++ b/samples/counter-coroutines/src/main/res/layout/counter_activity.xml @@ -0,0 +1,35 @@ + + + + + +