diff --git a/build.gradle.kts b/build.gradle.kts index c27440fe..0a72e5dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import java.nio.file.Paths + buildscript { repositories { mavenCentral() @@ -21,3 +23,65 @@ subprojects { tasks.register("clean") { delete(rootProject.buildDir) } + +/** + * =========================================================================================== + * BEGIN SECTION USING GRADLE TO BUILD, TRANSFORM AND INSTALL APP BUNDLE WITH DYNAMIC FEATURE + * SUPPORT TO LOCAL TESTING. On your terminal just run: + * + * ./gradlew runDynamicFeatureSample + * + * =========================================================================================== + */ + +val appBundleOriginPath = Paths.get("$rootDir","samples", "dynamic-feature", "app", "build", "outputs", "bundle", "debug") +val appBundleDestPath = Paths.get("${rootProject.buildDir}", "app-debug.aab") +val apksPath = Paths.get("${rootProject.buildDir}", "app.apks") +val bundleToolPath = Paths.get("${rootProject.buildDir}", "bundletool.jar") + +tasks.register("copyAab") { + dependsOn(":samples:dynamic-feature:app:bundleDebug") + + from(appBundleOriginPath) + into(rootProject.buildDir) + include("**/*.aab") +} + +tasks.register("downloadBundleTool") { + dependsOn("copyAab") + + doLast { + val dest = bundleToolPath.toFile() + if (!dest.exists()) { + ant.invokeMethod("get", mapOf( + "src" to "https://github.com/google/bundletool/releases/download/1.13.2/bundletool-all-1.13.2.jar", + "dest" to dest + )) + } + } +} + +tasks.register("cleanApks") { + delete(apksPath) +} + +tasks.register("generateApks") { + dependsOn("cleanApks", "downloadBundleTool") + + executable("java") + + args("-jar", "$bundleToolPath") + args("build-apks", "--local-testing") + args("--bundle", "$appBundleDestPath") + args("--output", "$apksPath") +} + +tasks.register("runDynamicFeatureSample") { + dependsOn("generateApks") + + executable("java") + + args("-jar", "$bundleToolPath") + args("install-apks") + args("--apks", "$apksPath") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19bde80c..3266dac6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -plugin-android = "8.0.0-alpha08" +plugin-android = "8.0.0-alpha11" plugin-ktlint = "11.0.0" plugin-maven = "0.22.0" plugin-multiplatform-compose = "1.2.1" @@ -15,6 +15,8 @@ lifecycle = "2.5.1" compose = "1.3.1" composeActivity = "1.6.1" +playCore = "1.10.3" + junit = "5.8.2" [libraries] @@ -25,6 +27,7 @@ plugin-maven = { module = "com.vanniktech:gradle-maven-publish-plugin", version. plugin-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } plugin-multiplatform-compose = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "plugin-multiplatform-compose" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } leakCanary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakCanary" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kodein = { module = "org.kodein.di:kodein-di-framework-compose", version.ref = "kodein" } @@ -49,5 +52,7 @@ compose-activity = { module = "androidx.activity:activity-compose", version.ref composeMultiplatform-runtimeSaveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "plugin-multiplatform-compose" } +play-core = { module = "com.google.android.play:core", version.ref = "playCore" } + junit-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/includes.gradle.kts b/includes.gradle.kts index 8a978ffa..51e20afe 100644 --- a/includes.gradle.kts +++ b/includes.gradle.kts @@ -1,5 +1,9 @@ include( ":samples:android", + ":samples:dynamic-feature:app", + ":samples:dynamic-feature:details", + ":samples:dynamic-feature:home", + ":samples:dynamic-feature:navigation", ":samples:multiplatform", ":samples:multi-module:app", diff --git a/samples/dynamic-feature/app/.gitignore b/samples/dynamic-feature/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/samples/dynamic-feature/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/samples/dynamic-feature/app/build.gradle.kts b/samples/dynamic-feature/app/build.gradle.kts new file mode 100644 index 00000000..fc8e02ba --- /dev/null +++ b/samples/dynamic-feature/app/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("com.android.application") + kotlin("android") +} + +setupModuleForAndroidxCompose( + composeCompilerVersion = libs.versions.compose.get(), + withKotlinExplicitMode = false, +) + +android { + namespace = "cafe.adriel.voyager.dynamic.feature" + defaultConfig { + applicationId = "cafe.adriel.voyager.dynamic.feature" + } + dynamicFeatures += listOf( + ":samples:dynamic-feature:home", + ":samples:dynamic-feature:details" + ) +} + +dependencies { + api(projects.samples.dynamicFeature.navigation) + implementation(projects.voyagerNavigator) + + implementation(libs.appCompat) + implementation(libs.compose.activity) + implementation(libs.compose.material) + implementation(libs.compose.ui) + + implementation(libs.play.core) + + implementation("com.google.android.material:material:1.5.0") +} diff --git a/samples/dynamic-feature/app/proguard-rules.pro b/samples/dynamic-feature/app/proguard-rules.pro new file mode 100644 index 00000000..01639a19 --- /dev/null +++ b/samples/dynamic-feature/app/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.kts.kts.kts. +# +# 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 \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/AndroidManifest.xml b/samples/dynamic-feature/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1ea7e7fc --- /dev/null +++ b/samples/dynamic-feature/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/InitialScreen.kt b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/InitialScreen.kt new file mode 100644 index 00000000..3adf03ce --- /dev/null +++ b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/InitialScreen.kt @@ -0,0 +1,43 @@ +package cafe.adriel.voyager.dynamic.feature + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.registry.rememberScreen +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.dynamic.feature.module.HomeDynamicFeatureScreenProvider +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow + +internal object InitialScreen : Screen { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + + // Simulate navigation using ScreenRegistry + val homeScreen = rememberScreen(provider = HomeDynamicFeatureScreenProvider) + + Column { + Text( + text = "Hey, I'm the app entry point. Congratulations!!!!!", + fontSize = 32.sp, + modifier = Modifier + .background(color = Color.Cyan) + ) + Text( + text = "Click me to go to Home", + fontSize = 24.sp, + modifier = Modifier + .background(color = Color.Red) + .clickable { + navigator.push(homeScreen) + } + ) + } + } +} diff --git a/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/MainActivity.kt b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/MainActivity.kt new file mode 100644 index 00000000..a9bccde8 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/MainActivity.kt @@ -0,0 +1,29 @@ +package cafe.adriel.voyager.dynamic.feature + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.dynamic.feature.custom.dynamicScreen +import cafe.adriel.voyager.dynamic.feature.module.HomeDynamicFeatureScreenProvider +import cafe.adriel.voyager.dynamic.feature.navigation.DynamicFeatureNavigator + +class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + ScreenRegistry { + register { screenProvider -> + dynamicScreen(screenProvider) + } + } + + setContent { + DynamicFeatureNavigator( + activity = this@MainActivity, + screen = InitialScreen, + ) + } + } +} diff --git a/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/custom/ShowProgressToUserContent.kt b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/custom/ShowProgressToUserContent.kt new file mode 100644 index 00000000..c8ad9877 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/custom/ShowProgressToUserContent.kt @@ -0,0 +1,117 @@ +package cafe.adriel.voyager.dynamic.feature.custom + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.dynamic.feature.navigation.DynamicFeatureInstallState +import cafe.adriel.voyager.dynamic.feature.navigation.DynamicFeatureScreen +import cafe.adriel.voyager.dynamic.feature.navigation.DynamicFeatureScreenProvider +import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus + +fun dynamicScreen(screenProvider: DynamicFeatureScreenProvider): DynamicFeatureScreen = + DynamicFeatureScreen( + screenProvider = screenProvider, + content = { state, installedModules, requestUserConfirmation, retry -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ShowProgressToUserContent( + state = state, + installedModules = installedModules, + requestUserConfirmation = requestUserConfirmation, + retry = retry + ) + } + } + ) + +@Composable +internal fun ShowProgressToUserContent( + state: DynamicFeatureInstallState, + installedModules: Set, + requestUserConfirmation: (requestCode: Int) -> Unit, + retry: () -> Unit, +) { + val multiInstall = state.moduleNames.size > 1 + val names = state.moduleNames.joinToString(" - ") + + when (state.status) { + SplitInstallSessionStatus.DOWNLOADING -> { + LoadingComponent( + bytesDownloaded = state.bytesDownloaded, + totalBytesToDownload = state.totalBytesToDownload, + message = "Downloading $names", + ) + } + + SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> { + Text( + text = "We need user confirmation to download", + fontSize = 24.sp, + modifier = Modifier + .background(color = Color.Cyan) + .clickable { + requestUserConfirmation(1234) + } + ) + } + + SplitInstallSessionStatus.INSTALLED -> { + Text( + text = "Success installation of [$names].\nNavigation will happen automatically :D", + fontSize = 24.sp, + ) + } + + SplitInstallSessionStatus.INSTALLING -> { + LoadingComponent( + bytesDownloaded = state.bytesDownloaded, + totalBytesToDownload = state.totalBytesToDownload, + message = "Installing $names", + ) + } + + SplitInstallSessionStatus.FAILED -> { + Text( + text = "Error: ${state.errorCode} for module ${state.moduleNames}", + fontSize = 24.sp, + modifier = Modifier + .background(color = Color.Cyan) + .clickable { + retry.invoke() + } + ) + } + } +} + +@Composable +internal fun LoadingComponent( + bytesDownloaded: Long, + totalBytesToDownload: Long, + message: String +) { + Column { + LinearProgressIndicator( + progress = bytesDownloaded / totalBytesToDownload.toFloat() + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = message, + fontSize = 24.sp, + ) + } +} diff --git a/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/module/DetailsDynamicFeatureScreenProvider.kt b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/module/DetailsDynamicFeatureScreenProvider.kt new file mode 100644 index 00000000..fcae2517 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/module/DetailsDynamicFeatureScreenProvider.kt @@ -0,0 +1,8 @@ +package cafe.adriel.voyager.dynamic.feature.module + +import cafe.adriel.voyager.dynamic.feature.navigation.DynamicFeatureScreenProvider + +object DetailsDynamicFeatureScreenProvider : DynamicFeatureScreenProvider { + override val moduleName = "details" + override val entryPointer = "cafe.adriel.voyager.dynamic.feature.details.DetailsScreen" +} diff --git a/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/module/HomeDynamicFeatureScreenProvider.kt b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/module/HomeDynamicFeatureScreenProvider.kt new file mode 100644 index 00000000..07ef0f07 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/java/cafe/adriel/voyager/dynamic/feature/module/HomeDynamicFeatureScreenProvider.kt @@ -0,0 +1,8 @@ +package cafe.adriel.voyager.dynamic.feature.module + +import cafe.adriel.voyager.dynamic.feature.navigation.DynamicFeatureScreenProvider + +object HomeDynamicFeatureScreenProvider : DynamicFeatureScreenProvider { + override val moduleName = "home" + override val entryPointer = "cafe.adriel.voyager.dynamic.feature.home.HomeScreen" +} diff --git a/samples/dynamic-feature/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/samples/dynamic-feature/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/drawable/ic_launcher_background.xml b/samples/dynamic-feature/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/dynamic-feature/app/src/main/res/layout/activity_main.xml b/samples/dynamic-feature/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..17eab17b --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/samples/dynamic-feature/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/samples/dynamic-feature/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/samples/dynamic-feature/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/samples/dynamic-feature/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/samples/dynamic-feature/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/samples/dynamic-feature/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/samples/dynamic-feature/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/samples/dynamic-feature/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/samples/dynamic-feature/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/samples/dynamic-feature/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/samples/dynamic-feature/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/samples/dynamic-feature/app/src/main/res/values-night/themes.xml b/samples/dynamic-feature/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..82c68d27 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/values/colors.xml b/samples/dynamic-feature/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/values/strings.xml b/samples/dynamic-feature/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..517ecbf9 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Voyager Dynamic Feature + Home + Details + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/values/themes.xml b/samples/dynamic-feature/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..2bbcb2d8 --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/xml/backup_rules.xml b/samples/dynamic-feature/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..fa0f996d --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/app/src/main/res/xml/data_extraction_rules.xml b/samples/dynamic-feature/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9ee9997b --- /dev/null +++ b/samples/dynamic-feature/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/build/intermediates/ktLint/reporterProviders.bin b/samples/dynamic-feature/build/intermediates/ktLint/reporterProviders.bin new file mode 100644 index 00000000..32d12919 Binary files /dev/null and b/samples/dynamic-feature/build/intermediates/ktLint/reporterProviders.bin differ diff --git a/samples/dynamic-feature/build/intermediates/ktLint/reporters.bin b/samples/dynamic-feature/build/intermediates/ktLint/reporters.bin new file mode 100644 index 00000000..d5a0d616 Binary files /dev/null and b/samples/dynamic-feature/build/intermediates/ktLint/reporters.bin differ diff --git a/samples/dynamic-feature/details/.gitignore b/samples/dynamic-feature/details/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/samples/dynamic-feature/details/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/samples/dynamic-feature/details/build.gradle.kts b/samples/dynamic-feature/details/build.gradle.kts new file mode 100644 index 00000000..7acab7af --- /dev/null +++ b/samples/dynamic-feature/details/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("com.android.dynamic-feature") + kotlin("android") +} + +setupModuleForAndroidxCompose( + composeCompilerVersion = libs.versions.compose.get(), + withKotlinExplicitMode = false, +) + +android { + namespace = "cafe.adriel.voyager.dynamic.feature.details" +} + +dependencies { + implementation(projects.samples.dynamicFeature.app) + implementation(projects.voyagerNavigator) + + implementation(libs.appCompat) + implementation(libs.compose.activity) + implementation(libs.compose.material) + implementation(libs.compose.ui) +} diff --git a/samples/dynamic-feature/details/src/main/AndroidManifest.xml b/samples/dynamic-feature/details/src/main/AndroidManifest.xml new file mode 100644 index 00000000..10a48986 --- /dev/null +++ b/samples/dynamic-feature/details/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/details/src/main/java/cafe/adriel/voyager/dynamic/feature/details/DetailsScreen.kt b/samples/dynamic-feature/details/src/main/java/cafe/adriel/voyager/dynamic/feature/details/DetailsScreen.kt new file mode 100644 index 00000000..a4dfdab8 --- /dev/null +++ b/samples/dynamic-feature/details/src/main/java/cafe/adriel/voyager/dynamic/feature/details/DetailsScreen.kt @@ -0,0 +1,39 @@ +package cafe.adriel.voyager.dynamic.feature.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow + +class DetailsScreen : Screen { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + + Column { + Text( + text = "Hey, you are in the Details now. Congratulations!!!!!", + fontSize = 32.sp, + modifier = Modifier + .background(color = Color.Cyan) + ) + Text( + text = "Well, just click to go back to Home", + fontSize = 24.sp, + modifier = Modifier + .background(color = Color.Red) + .clickable { + // Well, just navigate as you like + navigator.pop() + } + ) + } + } +} diff --git a/samples/dynamic-feature/home/.gitignore b/samples/dynamic-feature/home/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/samples/dynamic-feature/home/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/samples/dynamic-feature/home/build.gradle.kts b/samples/dynamic-feature/home/build.gradle.kts new file mode 100644 index 00000000..9349f016 --- /dev/null +++ b/samples/dynamic-feature/home/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("com.android.dynamic-feature") + kotlin("android") +} + +setupModuleForAndroidxCompose( + composeCompilerVersion = libs.versions.compose.get(), + withKotlinExplicitMode = false, +) + +android { + namespace = "cafe.adriel.voyager.dynamic.feature.home" +} + +dependencies { + implementation(projects.samples.dynamicFeature.app) + implementation(projects.voyagerNavigator) + + implementation(libs.appCompat) + implementation(libs.compose.activity) + implementation(libs.compose.material) + implementation(libs.compose.ui) +} diff --git a/samples/dynamic-feature/home/src/main/AndroidManifest.xml b/samples/dynamic-feature/home/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1db7b4c9 --- /dev/null +++ b/samples/dynamic-feature/home/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/samples/dynamic-feature/home/src/main/java/cafe/adriel/voyager/dynamic/feature/home/HomeScreen.kt b/samples/dynamic-feature/home/src/main/java/cafe/adriel/voyager/dynamic/feature/home/HomeScreen.kt new file mode 100644 index 00000000..5b9a1219 --- /dev/null +++ b/samples/dynamic-feature/home/src/main/java/cafe/adriel/voyager/dynamic/feature/home/HomeScreen.kt @@ -0,0 +1,41 @@ +package cafe.adriel.voyager.dynamic.feature.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.dynamic.feature.custom.dynamicScreen +import cafe.adriel.voyager.dynamic.feature.module.DetailsDynamicFeatureScreenProvider +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow + +class HomeScreen : Screen { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + + Column { + Text( + text = "Hey, you are in the Home. Welcome!!!", + fontSize = 32.sp, + modifier = Modifier + .background(color = Color.Cyan) + ) + Text( + text = "Click here to go to Details", + fontSize = 24.sp, + modifier = Modifier + .background(color = Color.Red) + .clickable { + // Simulate navigation without ScreenRegistry + navigator.push(dynamicScreen(screenProvider = DetailsDynamicFeatureScreenProvider)) + } + ) + } + } +} diff --git a/samples/dynamic-feature/navigation/.gitignore b/samples/dynamic-feature/navigation/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/samples/dynamic-feature/navigation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/samples/dynamic-feature/navigation/build.gradle.kts b/samples/dynamic-feature/navigation/build.gradle.kts new file mode 100644 index 00000000..68003573 --- /dev/null +++ b/samples/dynamic-feature/navigation/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +setupModuleForAndroidxCompose( + composeCompilerVersion = libs.versions.compose.get(), + withKotlinExplicitMode = false, +) + +android { + namespace = "cafe.adriel.voyager.dynamic.feature.navigation" +} + +dependencies { + implementation(projects.voyagerNavigator) + + implementation(libs.compose.compiler) + implementation(libs.compose.runtime) + + implementation(libs.kotlin.reflect) + + implementation(libs.play.core) +} diff --git a/samples/dynamic-feature/navigation/src/main/AndroidManifest.xml b/samples/dynamic-feature/navigation/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6fdf74a4 --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureInstallState.kt b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureInstallState.kt new file mode 100644 index 00000000..0ff8def7 --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureInstallState.kt @@ -0,0 +1,15 @@ +package cafe.adriel.voyager.dynamic.feature.navigation + +/** + * Google Play Wrapper [com.google.android.play.core.splitinstall.SplitInstallSessionState] + */ +data class DynamicFeatureInstallState( + val status: Int = -1, + val bytesDownloaded: Long = 0, + val totalBytesToDownload: Long = 0, + val errorCode: Int = -1, + val sessionId: Int = Int.MIN_VALUE, + val hasTerminalStatus: Boolean = false, + val languages: List = emptyList(), + val moduleNames: List = emptyList(), +) diff --git a/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureManager.kt b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureManager.kt new file mode 100644 index 00000000..6fc43562 --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureManager.kt @@ -0,0 +1,65 @@ +package cafe.adriel.voyager.dynamic.feature.navigation + +import android.app.Activity +import com.google.android.play.core.splitinstall.SplitInstallManagerFactory +import com.google.android.play.core.splitinstall.SplitInstallRequest +import com.google.android.play.core.splitinstall.SplitInstallSessionState +import com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class DynamicFeatureManager( + private val activity: Activity +) : DynamicFeatureProvider, SplitInstallStateUpdatedListener { + // Creates an instance of SplitInstallManager. + private val splitInstallManager = SplitInstallManagerFactory.create(activity) + private val mutableState = MutableStateFlow(DynamicFeatureInstallState()) + private var currentState: SplitInstallSessionState? = null + + override val installedModules: Set + get() = splitInstallManager.installedModules + + override val state: StateFlow + get() = mutableState + + override fun onStateUpdate(state: SplitInstallSessionState) { + currentState = state + mutableState.value = DynamicFeatureInstallState( + status = state.status(), + bytesDownloaded = state.bytesDownloaded(), + totalBytesToDownload = state.totalBytesToDownload(), + errorCode = state.errorCode(), + sessionId = state.sessionId(), + hasTerminalStatus = state.hasTerminalStatus(), + languages = state.languages(), + moduleNames = state.moduleNames(), + ) + } + + override fun requestUserConfirmation(requestCode: Int) { + val valid = currentState ?: return + splitInstallManager.startConfirmationDialogForResult(valid, activity, requestCode) + } + + override fun install(vararg providers: DynamicFeatureScreenProvider) { + val request = SplitInstallRequest.newBuilder() + for (provider in providers) { + request.addModule(provider.moduleName) + } + splitInstallManager.startInstall(request.build()) + } + + override fun hasInstallAlready(moduleName: String): Boolean { + return splitInstallManager.installedModules.contains(moduleName) + } + + fun register() { + // Registers the listener. + splitInstallManager.registerListener(this) + } + + fun unregister() { + // Unregisters the listener. + splitInstallManager.unregisterListener(this) + } +} diff --git a/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureNavigator.kt b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureNavigator.kt new file mode 100644 index 00000000..d40ddf15 --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureNavigator.kt @@ -0,0 +1,65 @@ +package cafe.adriel.voyager.dynamic.feature.navigation + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorContent +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior +import cafe.adriel.voyager.navigator.OnBackPressed + +@Composable +fun DynamicFeatureNavigator( + activity: Activity, // Activity is required when there is a require for user confirmation + screen: Screen, + disposeBehavior: NavigatorDisposeBehavior = NavigatorDisposeBehavior(), + onBackPressed: OnBackPressed = { true }, + content: NavigatorContent = { CurrentScreen() } +) { + DynamicFeatureNavigator( + activity = activity, + screens = listOf(screen), + disposeBehavior = disposeBehavior, + onBackPressed = onBackPressed, + content = content + ) +} + +@Composable +fun DynamicFeatureNavigator( + activity: Activity, // Activity is required when there is a require for user confirmation + screens: List, + disposeBehavior: NavigatorDisposeBehavior = NavigatorDisposeBehavior(), + onBackPressed: OnBackPressed = { true }, + content: NavigatorContent = { CurrentScreen() } +) { + val manager = remember { + DynamicFeatureManager(activity) + } + + LaunchedEffect(key1 = manager) { + manager.register() + } + + DisposableEffect(key1 = manager) { + onDispose { + manager.unregister() + } + } + + Navigator( + screens = screens, + disposeBehavior = disposeBehavior, + onBackPressed = onBackPressed, + ) { navigator -> + val lastScreen = navigator.lastItem + if (lastScreen is DynamicFeatureScreen) { + lastScreen.currentProvider = manager + } + content(navigator) + } +} diff --git a/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureProvider.kt b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureProvider.kt new file mode 100644 index 00000000..0844999f --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureProvider.kt @@ -0,0 +1,15 @@ +package cafe.adriel.voyager.dynamic.feature.navigation + +import kotlinx.coroutines.flow.StateFlow + +interface DynamicFeatureProvider { + val installedModules: Set + + val state: StateFlow + + fun hasInstallAlready(moduleName: String): Boolean + + fun install(vararg providers: DynamicFeatureScreenProvider) + + fun requestUserConfirmation(requestCode: Int) +} diff --git a/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureScreen.kt b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureScreen.kt new file mode 100644 index 00000000..e9bc8e8a --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureScreen.kt @@ -0,0 +1,77 @@ +package cafe.adriel.voyager.dynamic.feature.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus +import kotlin.reflect.full.createInstance + +/** + * Google Play best practices recommend show status to use while + * downloading a dynamic feature + */ +public typealias CustomDynamicScreenContent = @Composable ( + state: DynamicFeatureInstallState, + installedModules: Set, + requestUserConfirmation: (requestCode: Int) -> Unit, + retry: () -> Unit +) -> Unit + +class DynamicFeatureScreen( + private val screenProvider: DynamicFeatureScreenProvider, + private val content: CustomDynamicScreenContent, +) : Screen { + internal var currentProvider: DynamicFeatureProvider? = null + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val provider = safeProvider() + val state by provider.state.collectAsState() + var tryInstall by remember { + mutableStateOf(true) + } + + SideEffect { + if (tryInstall || state.status == SplitInstallSessionStatus.INSTALLED) { + tryInstallOrNavigate(navigator) + tryInstall = false + } + } + + content( + state = state, + installedModules = provider.installedModules, + requestUserConfirmation = provider::requestUserConfirmation, + retry = { + tryInstall = true + }, + ) + } + + private fun tryInstallOrNavigate(navigator: Navigator) { + val provider = safeProvider() + if (!provider.hasInstallAlready(screenProvider.moduleName)) { + provider.install(screenProvider) + } else { + val forName = Class.forName(screenProvider.entryPointer) + val entryPointerInstance = forName.kotlin.createInstance() + if (entryPointerInstance is Screen) { + navigator.replace(entryPointerInstance) + } else { + error("Your dynamic feature entry pointer must be a ${Screen::class.qualifiedName}") + } + } + } + + private fun safeProvider(): DynamicFeatureProvider = currentProvider + ?: error("No ${DynamicFeatureProvider::class.qualifiedName} found in the navigation scope") +} diff --git a/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureScreenProvider.kt b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureScreenProvider.kt new file mode 100644 index 00000000..8145aca9 --- /dev/null +++ b/samples/dynamic-feature/navigation/src/main/java/cafe/adriel/voyager/dynamic/feature/navigation/DynamicFeatureScreenProvider.kt @@ -0,0 +1,12 @@ +package cafe.adriel.voyager.dynamic.feature.navigation + +import cafe.adriel.voyager.core.registry.ScreenProvider + +/** + * As a [ScreenProvider] we can use [cafe.adriel.voyager.core.registry.ScreenRegistry] to + * register screens by type without directly use [DynamicFeatureScreen] + */ +interface DynamicFeatureScreenProvider : ScreenProvider { + val entryPointer: String + val moduleName: String +}