From b4dc27d49af9b0aa1c16ca18e8b1d93002a5a6eb Mon Sep 17 00:00:00 2001 From: Thiago Santos Date: Fri, 2 Feb 2024 18:40:25 -0300 Subject: [PATCH] feat: android activity routing integration --- build.gradle.kts | 2 - buildSrc/build.gradle.kts | 2 + gradle/libs.versions.toml | 7 +- integration/android/build.gradle.kts | 47 ++- integration/android/consumer-rules.pro | 0 integration/android/proguard-rules.pro | 0 .../android/src/main/AndroidManifest.xml | 18 +- .../routing/android/ActivityManager.kt | 71 +++++ .../routing/android/AndroidActivities.kt | 86 ++++++ .../routing/android/AndroidRouting.kt | 89 ++++++ .../routing/android/AndroidRoutingBuilder.kt | 98 ++++++ .../routing/android/AndroidRoutingExt.kt | 17 ++ .../routing/android/AndroidRoutingResource.kt | 65 ++++ .../android/AndroidStartActivityParams.kt | 37 +++ .../android/AndroidActivityManagerTest.kt | 69 +++++ .../routing/android/AndroidRoutingTest.kt | 278 ++++++++++++++++++ .../routing/android/fake/FakeActivitiy.kt | 27 ++ .../android/fake/FakeActivityManager.kt | 62 ++++ .../routing/resources/ResourceExecute.kt | 15 +- 19 files changed, 966 insertions(+), 24 deletions(-) create mode 100644 integration/android/consumer-rules.pro create mode 100644 integration/android/proguard-rules.pro create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/ActivityManager.kt create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidActivities.kt create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRouting.kt create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingBuilder.kt create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingExt.kt create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingResource.kt create mode 100644 integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidStartActivityParams.kt create mode 100644 integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidActivityManagerTest.kt create mode 100644 integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidRoutingTest.kt create mode 100644 integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivitiy.kt create mode 100644 integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivityManager.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0ed6bb3..23ac55f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,4 @@ plugins { alias(libs.plugins.jetbrains.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ktlint) apply false - alias(libs.plugins.android.application) apply false - alias(libs.plugins.android.library) apply false } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 508fe33..8e619dc 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,4 +10,6 @@ repositories { dependencies { implementation(libs.plugin.kotlin) implementation(libs.plugin.kover) + // FIXME: Kotlin and AGP plugins need to be loaded in the same place + implementation(libs.plugin.android) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c88ab86..21ceb7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -plugin-android = "8.2.1" +plugin-android = "8.2.2" plugin-kover = "0.7.5" plugin-ktlint = "12.1.0" plugin-maven = "0.27.0" @@ -17,6 +17,7 @@ slf4j = "2.0.4" voyager = "1.0.0" junit = "4.13.2" +robolectric = "4.11.1" # SAMPLE VERSIONS # core-ktx = "1.9.0" @@ -28,6 +29,7 @@ compose-bom = "2022.10.00" # SAMPLE VERSIONS # [libraries] +plugin-android = { module = "com.android.tools.build:gradle", version.ref = "plugin-android" } plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } plugin-kover = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "plugin-kover"} @@ -48,6 +50,7 @@ test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve test-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "coroutines" } test-junit = { module = "junit:junit", version.ref = "junit" } test-kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +test-robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } # SAMPLE LIBRARIES # core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -68,8 +71,6 @@ material3 = { group = "androidx.compose.material3", name = "material3" } # SAMPLE LIBRARIES # [plugins] -android-application = { id = "com.android.application", version.ref = "plugin-android" } -android-library = { id = "com.android.library", version.ref = "plugin-android" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "plugin-ktlint"} diff --git a/integration/android/build.gradle.kts b/integration/android/build.gradle.kts index 549615b..1500364 100644 --- a/integration/android/build.gradle.kts +++ b/integration/android/build.gradle.kts @@ -1,24 +1,61 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { - alias(libs.plugins.android.library) + id("com.android.library") + kotlin("android") id("org.jlleitschuh.gradle.ktlint") id("org.jetbrains.kotlinx.kover") alias(libs.plugins.maven.publish) } android { - namespace = "dev.programadorthi.routing.android" compileSdk = 34 + namespace = "dev.programadorthi.routing.android" defaultConfig { minSdk = 23 + consumerProguardFiles("consumer-rules.pro") + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + freeCompilerArgs += listOf("-Xexplicit-api=strict") } } dependencies { api(projects.resources) -} \ No newline at end of file + implementation(libs.androidx.startup) + + testImplementation(kotlin("test")) + testImplementation(libs.test.junit) + testImplementation(libs.test.coroutines) + testImplementation(libs.test.coroutines.debug) + testImplementation(libs.test.kotlin.test.junit) + testImplementation(libs.test.robolectric) +} diff --git a/integration/android/consumer-rules.pro b/integration/android/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/integration/android/proguard-rules.pro b/integration/android/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/integration/android/src/main/AndroidManifest.xml b/integration/android/src/main/AndroidManifest.xml index 568741e..3545a5d 100644 --- a/integration/android/src/main/AndroidManifest.xml +++ b/integration/android/src/main/AndroidManifest.xml @@ -1,2 +1,18 @@ - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/ActivityManager.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/ActivityManager.kt new file mode 100644 index 0000000..c8e0a01 --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/ActivityManager.kt @@ -0,0 +1,71 @@ +package dev.programadorthi.routing.android + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import dev.programadorthi.routing.core.RouteMethod +import dev.programadorthi.routing.core.application.ApplicationCall +import dev.programadorthi.routing.core.application.call +import io.ktor.util.pipeline.PipelineContext +import java.lang.ref.WeakReference + +@PublishedApi +internal interface ActivityManager : Application.ActivityLifecycleCallbacks { + fun currentActivity(): Activity + + fun start( + pipelineContext: PipelineContext, + intent: Intent, + ) +} + +internal class AndroidActivityManager : ActivityManager { + private var currentActivity = WeakReference(null) + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) { + } + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) { + } + + override fun onActivityDestroyed(activity: Activity) {} + + override fun onActivityStarted(activity: Activity) { + currentActivity = WeakReference(activity) + } + + override fun currentActivity(): Activity = + currentActivity.get() ?: error( + "Activity manager not started. Please, install AndroidActivities plugin to your route", + ) + + override fun start( + pipelineContext: PipelineContext, + intent: Intent, + ) = with(pipelineContext) { + val activity = currentActivity() + val options = call.activityOptions + when (val requestCode = call.requestCode) { + null -> activity.startActivity(intent, options) + else -> activity.startActivityForResult(intent, requestCode, options) + } + + when (call.routeMethod) { + RouteMethod.Replace -> activity.finishAfterTransition() + RouteMethod.ReplaceAll -> activity.finishAffinity() + } + } +} diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidActivities.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidActivities.kt new file mode 100644 index 0000000..3bcea8b --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidActivities.kt @@ -0,0 +1,86 @@ +package dev.programadorthi.routing.android + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import dev.programadorthi.routing.core.Routing +import dev.programadorthi.routing.core.application +import dev.programadorthi.routing.core.application.Application +import dev.programadorthi.routing.core.application.ApplicationCall +import dev.programadorthi.routing.core.application.RouteScopedPlugin +import dev.programadorthi.routing.core.application.createRouteScopedPlugin +import dev.programadorthi.routing.core.application.plugin +import dev.programadorthi.routing.core.application.pluginOrNull +import dev.programadorthi.routing.core.asRouting +import io.ktor.util.AttributeKey +import io.ktor.utils.io.KtorDsl +import android.app.Application as AndroidApplication + +internal val AndroidActivityManagerKey = + AttributeKey("AndroidActivityManagerKey") + +public val AndroidActivities: RouteScopedPlugin = + createRouteScopedPlugin("AndroidActivities", ::AndroidActivitiesConfig) { + + val context = pluginConfig.context + val parentRouting = application.pluginOrNull(Routing) + + var manager = + generateSequence(seed = parentRouting) { it.parent?.asRouting } + .mapNotNull { it.attributes.getOrNull(AndroidActivityManagerKey) } + .firstOrNull() + + if (manager == null) { + val applicationContext = + when (context) { + is AndroidApplication -> context + else -> context.applicationContext + } + val application = applicationContext as AndroidApplication + manager = pluginConfig.manager + application.registerActivityLifecycleCallbacks(manager) + } + + val activity = + generateSequence(seed = context) { (it as? ContextWrapper)?.baseContext } + .mapNotNull { it as? Activity } + .firstOrNull() + + if (activity != null) { + manager.onActivityStarted(activity) + } + + application.attributes.put(AndroidActivityManagerKey, manager) + + onCall { call -> + call.attributes.put(AndroidActivityManagerKey, manager) + } + } + +/** + * A configuration for the [AndroidActivities] plugin. + */ +@KtorDsl +public class AndroidActivitiesConfig { + public lateinit var context: Context + + internal var manager: ActivityManager = AndroidActivityManager() +} + +@PublishedApi +internal val ApplicationCall.activityManager: ActivityManager + get() = application.activityManager() + +internal val Routing.activityManager: ActivityManager + get() = application.activityManager() + +private fun Application.activityManager(): ActivityManager { + return when (val manager = attributes.getOrNull(AndroidActivityManagerKey)) { + null -> { + plugin(AndroidActivities) + error("There is no started activity manager. Please, install AndroidActivities plugin") + } + + else -> manager + } +} diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRouting.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRouting.kt new file mode 100644 index 0000000..3c53f34 --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRouting.kt @@ -0,0 +1,89 @@ +package dev.programadorthi.routing.android + +import android.os.Bundle +import dev.programadorthi.routing.core.RouteMethod +import dev.programadorthi.routing.core.Routing +import dev.programadorthi.routing.core.application +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.http.Parameters +import io.ktor.util.Attributes + +public fun Routing.pushActivity( + name: String = "", + path: String = "", + requestCode: Int? = null, + activityOptions: Bundle? = null, + attributes: Attributes = Attributes(), + parameters: Parameters = Parameters.Empty, +) { + execute( + routeMethod = RouteMethod.Push, + name = name, + path = path, + requestCode = requestCode, + activityOptions = activityOptions, + attributes = attributes, + parameters = parameters, + ) +} + +public fun Routing.replaceActivity( + name: String = "", + path: String = "", + requestCode: Int? = null, + activityOptions: Bundle? = null, + attributes: Attributes = Attributes(), + parameters: Parameters = Parameters.Empty, +) { + execute( + routeMethod = RouteMethod.Replace, + name = name, + path = path, + requestCode = requestCode, + activityOptions = activityOptions, + attributes = attributes, + parameters = parameters, + ) +} + +public fun Routing.replaceAllActivity( + name: String = "", + path: String = "", + requestCode: Int? = null, + activityOptions: Bundle? = null, + attributes: Attributes = Attributes(), + parameters: Parameters = Parameters.Empty, +) { + execute( + routeMethod = RouteMethod.ReplaceAll, + name = name, + path = path, + requestCode = requestCode, + activityOptions = activityOptions, + attributes = attributes, + parameters = parameters, + ) +} + +private fun Routing.execute( + routeMethod: RouteMethod, + name: String = "", + path: String = "", + requestCode: Int?, + activityOptions: Bundle?, + attributes: Attributes = Attributes(), + parameters: Parameters = Parameters.Empty, +) { + val call = + ApplicationCall( + application = application, + name = name, + uri = path, + routeMethod = routeMethod, + attributes = attributes, + parameters = parameters, + ) + call.requestCode = requestCode + call.activityOptions = activityOptions + execute(call) +} diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingBuilder.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingBuilder.kt new file mode 100644 index 0000000..d98fae2 --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingBuilder.kt @@ -0,0 +1,98 @@ +package dev.programadorthi.routing.android + +import android.app.Activity +import android.content.Intent +import dev.programadorthi.routing.core.Route +import dev.programadorthi.routing.core.RouteMethod +import dev.programadorthi.routing.core.application.ApplicationCall +import dev.programadorthi.routing.core.application.call +import dev.programadorthi.routing.core.route +import dev.programadorthi.routing.resources.handle +import io.ktor.util.pipeline.PipelineContext +import io.ktor.utils.io.KtorDsl + +@KtorDsl +public fun Route.activity( + path: String, + name: String? = null, + body: PipelineContext.() -> Intent, +): Route = route(path = path, name = name) { activity(body) } + +@KtorDsl +public fun Route.activity( + path: String, + method: RouteMethod, + name: String? = null, + body: PipelineContext.() -> Intent, +): Route = route(path = path, name = name, method = method) { activity(body) } + +@KtorDsl +public fun Route.activity(body: PipelineContext.() -> Intent) { + handle { + call.activityManager.start(this, body(this)) + } +} + +@KtorDsl +public inline fun Route.activity( + path: String, + name: String? = null, + crossinline body: PipelineContext.(Intent) -> Unit = {}, +): Route = route(path = path, name = name) { activity(body) } + +@KtorDsl +public inline fun Route.activity( + path: String, + method: RouteMethod, + name: String? = null, + crossinline body: PipelineContext.(Intent) -> Unit = {}, +): Route = route(path = path, name = name, method = method) { activity(body) } + +@KtorDsl +public inline fun Route.activity(crossinline body: PipelineContext.(Intent) -> Unit = {}) { + handle { + val intent = Intent(call.currentActivity, A::class.java) + body(this, intent) + call.activityManager.start(this, intent) + } +} + +@KtorDsl +public inline fun Route.activity(noinline body: PipelineContext.(T) -> Intent): Route { + return handle { resource -> + call.activityManager.start(this, body(this, resource)) + } +} + +@KtorDsl +public inline fun Route.activity( + method: RouteMethod, + noinline body: PipelineContext.(T) -> Intent, +): Route { + return handle(method = method) { resource -> + call.activityManager.start(this, body(this, resource)) + } +} + +@KtorDsl +public inline fun Route.activity( + noinline body: PipelineContext.(T, Intent) -> Unit = { _, _ -> }, +): Route { + return handle { resource -> + val intent = Intent(call.currentActivity, A::class.java) + body(this, resource, intent) + call.activityManager.start(this, intent) + } +} + +@KtorDsl +public inline fun Route.activity( + method: RouteMethod, + noinline body: PipelineContext.(T, Intent) -> Unit = { _, _ -> }, +): Route { + return handle(method = method) { resource -> + val intent = Intent(call.currentActivity, A::class.java) + body(this, resource, intent) + call.activityManager.start(this, intent) + } +} diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingExt.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingExt.kt new file mode 100644 index 0000000..6a8bdf9 --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingExt.kt @@ -0,0 +1,17 @@ +package dev.programadorthi.routing.android + +import android.app.Activity +import android.content.Intent +import dev.programadorthi.routing.core.Routing + +public fun Routing.popActivity(result: Intent? = null) { + val activity = activityManager.currentActivity() + + if (result != null) { + activity.setResult(Activity.RESULT_OK, result) + } else { + activity.setResult(Activity.RESULT_CANCELED) + } + + activity.finishAfterTransition() +} diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingResource.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingResource.kt new file mode 100644 index 0000000..8e2e1ab --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidRoutingResource.kt @@ -0,0 +1,65 @@ +package dev.programadorthi.routing.android + +import android.os.Bundle +import dev.programadorthi.routing.core.RouteMethod +import dev.programadorthi.routing.core.Routing +import dev.programadorthi.routing.core.application +import dev.programadorthi.routing.core.application.ApplicationCall +import dev.programadorthi.routing.resources.href + +public inline fun Routing.pushActivity( + resource: T, + requestCode: Int? = null, + activityOptions: Bundle? = null, +) { + execute( + resource = resource, + routeMethod = RouteMethod.Push, + requestCode = requestCode, + activityOptions = activityOptions, + ) +} + +public inline fun Routing.replaceActivity( + resource: T, + requestCode: Int? = null, + activityOptions: Bundle? = null, +) { + execute( + resource = resource, + routeMethod = RouteMethod.Replace, + requestCode = requestCode, + activityOptions = activityOptions, + ) +} + +public inline fun Routing.replaceAllActivity( + resource: T, + requestCode: Int? = null, + activityOptions: Bundle? = null, +) { + execute( + resource = resource, + routeMethod = RouteMethod.ReplaceAll, + requestCode = requestCode, + activityOptions = activityOptions, + ) +} + +@PublishedApi +internal inline fun Routing.execute( + resource: T, + routeMethod: RouteMethod, + requestCode: Int?, + activityOptions: Bundle?, +) { + val call = + ApplicationCall( + application = application, + uri = application.href(resource), + routeMethod = routeMethod, + ) + call.requestCode = requestCode + call.activityOptions = activityOptions + execute(call) +} diff --git a/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidStartActivityParams.kt b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidStartActivityParams.kt new file mode 100644 index 0000000..9ca630f --- /dev/null +++ b/integration/android/src/main/kotlin/dev/programadorthi/routing/android/AndroidStartActivityParams.kt @@ -0,0 +1,37 @@ +package dev.programadorthi.routing.android + +import android.app.Activity +import android.os.Bundle +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.AttributeKey + +private val AndroidActivityOptionsAttributeKey: AttributeKey = + AttributeKey("AndroidActivityOptionsAttributeKey") + +private val AndroidRequestCodeAttributeKey: AttributeKey = + AttributeKey("AndroidRequestCodeAttributeKey") + +@PublishedApi +internal var ApplicationCall.activityOptions: Bundle? + get() = attributes.getOrNull(AndroidActivityOptionsAttributeKey) + set(value) { + if (value != null) { + attributes.put(AndroidActivityOptionsAttributeKey, value) + } else { + attributes.remove(AndroidActivityOptionsAttributeKey) + } + } + +@PublishedApi +internal var ApplicationCall.requestCode: Int? + get() = attributes.getOrNull(AndroidRequestCodeAttributeKey) + set(value) { + if (value != null) { + attributes.put(AndroidRequestCodeAttributeKey, value) + } else { + attributes.remove(AndroidRequestCodeAttributeKey) + } + } + +public val ApplicationCall.currentActivity: Activity + get() = activityManager.currentActivity() diff --git a/integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidActivityManagerTest.kt b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidActivityManagerTest.kt new file mode 100644 index 0000000..2576744 --- /dev/null +++ b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidActivityManagerTest.kt @@ -0,0 +1,69 @@ +package dev.programadorthi.routing.android + +import dev.programadorthi.routing.android.fake.FakeActivityA +import dev.programadorthi.routing.android.fake.FakeActivityB +import dev.programadorthi.routing.android.fake.FakeActivityC +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertIs + +@RunWith(RobolectricTestRunner::class) +internal class AndroidActivityManagerTest { + @Test + fun shouldThrowExceptionWhenGettingAnNullActivity() { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val manager = AndroidActivityManager() + application.registerActivityLifecycleCallbacks(manager) + + val exception = + assertFails { + // WHEN + manager.currentActivity() + } + + // THEN + assertIs(exception) + assertEquals( + "Activity manager not started. Please, install AndroidActivities plugin to your route", + exception.message, + ) + } + + @Test + fun shouldNotThrowExceptionWhenGettingAnActivityFromLifecycle() { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val manager = AndroidActivityManager() + application.registerActivityLifecycleCallbacks(manager) + + // WHEN + Robolectric.buildActivity(FakeActivityA::class.java).setup() + + // THEN + val activity = manager.currentActivity() + assertIs(activity) + } + + @Test + fun shouldLastStartedActivityBeEqualsToCurrentActivity() { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val manager = AndroidActivityManager() + application.registerActivityLifecycleCallbacks(manager) + + // WHEN + Robolectric.buildActivity(FakeActivityA::class.java).setup() + Robolectric.buildActivity(FakeActivityB::class.java).setup() + Robolectric.buildActivity(FakeActivityC::class.java).setup() + + // THEN + val activity = manager.currentActivity() + assertIs(activity) + } +} diff --git a/integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidRoutingTest.kt b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidRoutingTest.kt new file mode 100644 index 0000000..9212210 --- /dev/null +++ b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/AndroidRoutingTest.kt @@ -0,0 +1,278 @@ +package dev.programadorthi.routing.android + +import android.app.Activity +import android.app.ActivityOptions +import android.content.Intent +import dev.programadorthi.routing.android.fake.FakeActivityA +import dev.programadorthi.routing.android.fake.FakeActivityB +import dev.programadorthi.routing.android.fake.FakeActivityC +import dev.programadorthi.routing.android.fake.FakeActivityManager +import dev.programadorthi.routing.core.application.call +import dev.programadorthi.routing.core.call +import dev.programadorthi.routing.core.install +import dev.programadorthi.routing.core.routing +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class AndroidRoutingTest { + @Test + fun shouldStartActivityUsingGenericCall() = + runTest { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = Activity::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeA") + } + + // WHEN + routing.call(uri = "/fakeA") + advanceTimeBy(99) // Ask for routing + + // THEN + val activity = fakeActivityManager.currentActivity() + assertIs(activity) + } + + @Test + fun shouldPushAnActivity() = + runTest { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = Activity::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeA") { + Intent(call.currentActivity, FakeActivityA::class.java) + } + } + + // WHEN + routing.pushActivity(path = "/fakeA") + advanceTimeBy(99) // Ask for routing + + // THEN + val activity = fakeActivityManager.currentActivity() + assertIs(activity) + } + + @Test + fun shouldReplaceAnActivity() = + runTest { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = FakeActivityA::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeB") + activity(path = "/fakeC") + } + + // WHEN + routing.pushActivity(path = "/fakeB") + advanceTimeBy(99) // Ask for routing + val activityB = fakeActivityManager.currentActivity() + + // WHEN + routing.replaceActivity(path = "/fakeC") + advanceTimeBy(99) // Ask for routing + + // THEN + val activityC = fakeActivityManager.currentActivity() + + assertIs(activityB) + assertIs(activityC) + assertTrue( + activityB.isFinished, + "Previous activity should be finished after a replace call", + ) + } + + @Test + fun shouldReplaceAllActivity() = + runTest { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = FakeActivityA::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeB") { + Intent(call.currentActivity, FakeActivityB::class.java) + } + + activity(path = "/fakeC") { + Intent(call.currentActivity, FakeActivityC::class.java) + } + } + + // WHEN + routing.pushActivity(path = "/fakeB") + advanceTimeBy(99) // Ask for routing + val activityB = fakeActivityManager.currentActivity() + + // WHEN + routing.replaceAllActivity(path = "/fakeC") + advanceTimeBy(99) // Ask for routing + + // THEN + val activityC = fakeActivityManager.currentActivity() + + assertIs(activityB) + assertIs(activityC) + assertTrue( + activityB.isFinishedAffinity, + "Previous activity should call finishAffinity() after a replace all call", + ) + } + + @Test + fun shouldStartActivityForResult() = + runTest { + // GIVEN + val requestCode = 12345678 + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = FakeActivityA::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeB") + } + + // WHEN + routing.pushActivity(path = "/fakeB", requestCode = requestCode) + advanceTimeBy(99) // Ask for routing + val shadowActivity = Shadows.shadowOf(fakeActivityManager.currentActivity()) + + routing.popActivity() + advanceTimeBy(99) // Ask for routing + + // THEN + val intentForResult = shadowActivity.nextStartedActivityForResult + assertEquals( + FakeActivityB::class.qualifiedName, + intentForResult.intent?.component?.className, + ) + assertEquals(requestCode, intentForResult.requestCode) + assertEquals(Activity.RESULT_CANCELED, shadowActivity.resultCode) + assertNull( + shadowActivity.resultIntent, + "result intent should be null after pop without a value", + ) + } + + @Test + fun shouldStartActivityForResultAndReceiveTheResult() = + runTest { + // GIVEN + val requestCode = 12345678 + val resultData = + Intent().apply { + putExtra("key", "value") + } + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = FakeActivityA::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeB") + } + + // WHEN + routing.pushActivity(path = "/fakeB", requestCode = requestCode) + advanceTimeBy(99) // Ask for routing + val shadowActivity = Shadows.shadowOf(fakeActivityManager.currentActivity()) + + routing.popActivity(result = resultData) + advanceTimeBy(99) // Ask for routing + + // THEN + val intentForResult = shadowActivity.nextStartedActivityForResult + assertEquals( + FakeActivityB::class.qualifiedName, + intentForResult.intent?.component?.className, + ) + assertEquals(requestCode, intentForResult.requestCode) + assertEquals(Activity.RESULT_OK, shadowActivity.resultCode) + assertEquals(resultData, shadowActivity.resultIntent) + } + + @Test + fun shouldStartActivityWithOptions() = + runTest { + // GIVEN + val application = RuntimeEnvironment.getApplication() + val job = Job() + val fakeActivityManager = FakeActivityManager(mainActivity = FakeActivityA::class.java) + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + install(AndroidActivities) { + context = application + manager = fakeActivityManager + } + + activity(path = "/fakeB") + } + + // WHEN + val shadowActivity = Shadows.shadowOf(fakeActivityManager.currentActivity()) + + routing.pushActivity( + path = "/fakeB", + activityOptions = ActivityOptions.makeBasic().toBundle(), + ) + advanceTimeBy(99) // Ask for routing + + // THEN + val intentForResult = shadowActivity.nextStartedActivityForResult + assertNotNull(intentForResult.options, "Options should not be null when provided") + } +} diff --git a/integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivitiy.kt b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivitiy.kt new file mode 100644 index 0000000..fc19a64 --- /dev/null +++ b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivitiy.kt @@ -0,0 +1,27 @@ +package dev.programadorthi.routing.android.fake + +import android.app.Activity + +internal abstract class BaseFakeActivity : Activity() { + internal var isFinished = false + private set + + internal var isFinishedAffinity = false + private set + + override fun finishAfterTransition() { + isFinished = true + super.finishAfterTransition() + } + + override fun finishAffinity() { + isFinishedAffinity = true + super.finishAffinity() + } +} + +internal class FakeActivityA : BaseFakeActivity() + +internal class FakeActivityB : BaseFakeActivity() + +internal class FakeActivityC : BaseFakeActivity() diff --git a/integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivityManager.kt b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivityManager.kt new file mode 100644 index 0000000..8f950ce --- /dev/null +++ b/integration/android/src/test/kotlin/dev/programadorthi/routing/android/fake/FakeActivityManager.kt @@ -0,0 +1,62 @@ +package dev.programadorthi.routing.android.fake + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import dev.programadorthi.routing.android.ActivityManager +import dev.programadorthi.routing.android.AndroidActivityManager +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.pipeline.PipelineContext +import org.robolectric.Robolectric +import org.robolectric.android.controller.ActivityController + +internal class FakeActivityManager( + mainActivity: Class, +) : ActivityManager { + private val manager = AndroidActivityManager() + + init { + @Suppress("UNCHECKED_CAST") + val controller = setup(mainActivity as Class) + manager.onActivityStarted(controller.get()) + } + + override fun currentActivity(): Activity = manager.currentActivity() + + @Suppress("UNCHECKED_CAST") + override fun start( + pipelineContext: PipelineContext, + intent: Intent, + ) { + manager.start(pipelineContext, intent) + + val name = intent.component?.className ?: error("No class name provided on the Intent") + val activityClass = Class.forName(name) as Class + setup(activityClass) // Will trigger onActivityStarted + } + + override fun onActivityStarted(activity: Activity) { + manager.onActivityStarted(activity) + } + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivityDestroyed(activity: Activity) {} + + private fun setup(activityClass: Class): ActivityController = + Robolectric.buildActivity(activityClass).setup() as ActivityController +} diff --git a/resources/common/src/dev/programadorthi/routing/resources/ResourceExecute.kt b/resources/common/src/dev/programadorthi/routing/resources/ResourceExecute.kt index e5db014..743e799 100644 --- a/resources/common/src/dev/programadorthi/routing/resources/ResourceExecute.kt +++ b/resources/common/src/dev/programadorthi/routing/resources/ResourceExecute.kt @@ -5,9 +5,8 @@ import dev.programadorthi.routing.core.Routing import dev.programadorthi.routing.core.application import dev.programadorthi.routing.core.application.ApplicationCall import dev.programadorthi.routing.core.application.pluginOrNull +import dev.programadorthi.routing.core.application.redirectToPath import dev.programadorthi.routing.core.call -import io.ktor.util.pipeline.execute -import kotlinx.coroutines.launch public inline fun Routing.call( resource: T, @@ -23,17 +22,7 @@ public inline fun ApplicationCall.redirectTo(resource: T) { checkNotNull(application.pluginOrNull(Resources)) { "Resources plugin not installed" } - with(application) { - launch { - execute( - ApplicationCall( - application = application, - uri = href(resource), - routeMethod = routeMethod, - ), - ) - } - } + redirectToPath(path = application.href(resource)) } public inline fun Routing.push(resource: T) {