Repository: amzn/app-platform
Branch: main
Commit: 4f9b6301e4da
Files: 621
Total size: 1.6 MB
Directory structure:
gitextract_au9h9jge/
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── actions/
│ │ ├── prepare-emulator-action/
│ │ │ └── action.yml
│ │ └── setup-action/
│ │ └── action.yml
│ └── workflows/
│ ├── blueprints-starter-ci.yml
│ ├── ci.yml
│ ├── pages.yml
│ ├── publish-release.yml
│ └── publish-snapshot.yml
├── .gitignore
├── .idea/
│ └── ktfmt.xml
├── AGENTS.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── NOTICE
├── README.md
├── RELEASING.md
├── blueprints/
│ ├── README.md
│ └── starter/
│ ├── .gitignore
│ ├── README.md
│ ├── app/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── androidMain/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── kotlin/
│ │ │ │ └── software/
│ │ │ │ └── amazon/
│ │ │ │ └── app/
│ │ │ │ └── platform/
│ │ │ │ └── template/
│ │ │ │ ├── AndroidAppGraph.kt
│ │ │ │ ├── AndroidApplication.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── MainActivityViewModel.kt
│ │ │ └── res/
│ │ │ └── values/
│ │ │ └── strings.xml
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── template/
│ │ │ ├── AppGraph.kt
│ │ │ ├── Application.kt
│ │ │ └── TemplateProvider.kt
│ │ ├── desktopMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── template/
│ │ │ ├── DesktopApp.kt
│ │ │ ├── DesktopAppGraph.kt
│ │ │ └── Main.kt
│ │ ├── iosMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── template/
│ │ │ ├── IosAppGraph.kt
│ │ │ └── MainViewController.kt
│ │ └── wasmJsMain/
│ │ ├── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── template/
│ │ │ ├── Main.kt
│ │ │ └── WasmJsAppGraph.kt
│ │ └── resources/
│ │ ├── index.html
│ │ └── styles.css
│ ├── build.gradle.kts
│ ├── gradle/
│ │ ├── libs.versions.toml
│ │ └── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── iosApp/
│ │ ├── Configuration/
│ │ │ └── Config.xcconfig
│ │ ├── iosApp/
│ │ │ ├── Assets.xcassets/
│ │ │ │ ├── AccentColor.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset/
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── ComposeContentView.swift
│ │ │ ├── Info.plist
│ │ │ ├── Preview Content/
│ │ │ │ └── Preview Assets.xcassets/
│ │ │ │ └── Contents.json
│ │ │ └── iOSApp.swift
│ │ └── iosApp.xcodeproj/
│ │ ├── project.pbxproj
│ │ ├── project.xcworkspace/
│ │ │ └── contents.xcworkspacedata
│ │ └── xcshareddata/
│ │ └── xcschemes/
│ │ └── iosApp.xcscheme
│ ├── navigation/
│ │ ├── impl/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── software/
│ │ │ │ └── amazon/
│ │ │ │ └── app/
│ │ │ │ └── platform/
│ │ │ │ └── template/
│ │ │ │ └── navigation/
│ │ │ │ ├── ExampleRepositoryImpl.kt
│ │ │ │ ├── ExampleValueGenerator.kt
│ │ │ │ ├── NavigationDetailPresenterImpl.kt
│ │ │ │ ├── NavigationDetailRenderer.kt
│ │ │ │ ├── NavigationHeaderPresenterImpl.kt
│ │ │ │ ├── NavigationHeaderRenderer.kt
│ │ │ │ └── NavigationPresenterImpl.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── template/
│ │ │ └── navigation/
│ │ │ ├── NavigationDetailPresenterTest.kt
│ │ │ ├── NavigationHeaderPresenterTest.kt
│ │ │ └── NavigationPresenterImplTest.kt
│ │ ├── public/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── template/
│ │ │ └── navigation/
│ │ │ ├── ExampleRepository.kt
│ │ │ ├── NavigationDetailPresenter.kt
│ │ │ ├── NavigationHeaderPresenter.kt
│ │ │ └── NavigationPresenter.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── template/
│ │ └── navigation/
│ │ └── FakeExampleRepository.kt
│ ├── settings.gradle.kts
│ └── templates/
│ ├── impl/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── template/
│ │ └── templates/
│ │ └── ComposeAppTemplateRenderer.kt
│ └── public/
│ ├── build.gradle.kts
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── template/
│ └── templates/
│ ├── AppTemplate.kt
│ └── AppTemplatePresenter.kt
├── build.gradle
├── buildSrc/
│ ├── build.gradle
│ ├── settings.gradle
│ └── src/
│ └── main/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── gradle/
│ └── buildsrc/
│ ├── AppPlatformExtension.kt
│ ├── AppPlugin.kt
│ ├── BaseAndroidPlugin.kt
│ ├── BasePlugin.kt
│ ├── Gradle.kt
│ ├── JvmLibraryPlugin.kt
│ ├── KmpPlugin.kt
│ ├── LibraryPlugin.kt
│ ├── Platform.kt
│ ├── Plugins.kt
│ ├── RootPlugin.kt
│ └── SdkPlugin.kt
├── di-common/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ ├── inject/
│ │ ├── ContributesRenderer.kt
│ │ └── robot/
│ │ └── ContributesRobot.kt
│ ├── presenter/
│ │ └── PresenterCoroutineScope.kt
│ └── scope/
│ └── coroutine/
│ ├── DefaultCoroutineDispatcher.kt
│ ├── IoCoroutineDispatcher.kt
│ └── MainCoroutineDispatcher.kt
├── docs/
│ ├── di.md
│ ├── faq.md
│ ├── index.md
│ ├── module-structure.md
│ ├── presenter.md
│ ├── renderer.md
│ ├── scope.md
│ ├── setup.md
│ ├── template.md
│ └── testing.md
├── gradle/
│ ├── detekt-config.yml
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle-plugin/
│ ├── api/
│ │ └── gradle-plugin.api
│ ├── build.gradle
│ ├── settings.gradle
│ └── src/
│ └── main/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── gradle/
│ ├── AppPlatformExtension.kt
│ ├── AppPlatformPlugin.kt
│ ├── GradleExtensions.kt
│ ├── ModuleStructureDependencyCheckTask.kt
│ ├── ModuleStructurePlugin.kt
│ ├── ModuleType.kt
│ └── PluginIds.kt
├── gradle.properties
├── gradlew
├── gradlew.bat
├── internal/
│ └── testing/
│ ├── build.gradle
│ └── src/
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── internal/
│ │ ├── IgnoreNative.kt
│ │ ├── IgnoreWasm.android.kt
│ │ ├── Platform.kt
│ │ └── Thread.kt
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── internal/
│ │ ├── IgnoreNative.kt
│ │ ├── IgnoreWasm.kt
│ │ ├── Platform.kt
│ │ └── Thread.kt
│ ├── desktopMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── internal/
│ │ ├── IgnoreNative.kt
│ │ ├── IgnoreWasm.kt
│ │ ├── Platform.kt
│ │ └── Thread.kt
│ ├── nativeMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── internal/
│ │ ├── IgnoreNative.kt
│ │ ├── IgnoreWasm.kt
│ │ ├── Platform.kt
│ │ └── Thread.kt
│ └── wasmJsMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── internal/
│ ├── IgnoreNative.kt
│ ├── IgnoreWasm.kt
│ ├── Platform.kt
│ └── Thread.kt
├── ios-run.sh
├── kotlin-inject/
│ ├── impl/
│ │ ├── api/
│ │ │ ├── android/
│ │ │ │ └── impl.api
│ │ │ └── desktop/
│ │ │ └── impl.api
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ ├── presenter/
│ │ │ │ └── PresenterCoroutineScopeComponent.kt
│ │ │ └── scope/
│ │ │ └── coroutine/
│ │ │ ├── AppScopeCoroutineScopeComponent.kt
│ │ │ ├── CoroutineDispatcherComponent.kt
│ │ │ └── IoDispatcher.kt
│ │ ├── noWasmJsMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── scope/
│ │ │ └── coroutine/
│ │ │ └── IoDispatcher.kt
│ │ └── wasmJsMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── scope/
│ │ └── coroutine/
│ │ └── IoDispatcher.kt
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── scope/
│ │ └── di/
│ │ └── ComponentService.kt
│ └── commonTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── scope/
│ └── di/
│ └── ComponentServiceTest.kt
├── kotlin-inject-extensions/
│ └── contribute/
│ ├── impl-code-generators/
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── inject/
│ │ │ ├── KotlinInjectContextAware.kt
│ │ │ ├── KotlinInjectExtensionSymbolProcessorProvider.kt
│ │ │ ├── Util.kt
│ │ │ └── processor/
│ │ │ ├── ContributesBindingProcessor.kt
│ │ │ ├── ContributesBindingScopedProcessor.kt
│ │ │ ├── ContributesMockImplProcessor.kt
│ │ │ ├── ContributesRealImplProcessor.kt
│ │ │ ├── ContributesRendererProcessor.kt
│ │ │ └── ContributesRobotProcessor.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── inject/
│ │ ├── CommonSourceCode.kt
│ │ ├── Compilation.kt
│ │ ├── CompilerTestUtil.kt
│ │ └── processor/
│ │ ├── ContributesBindingProcessorTest.kt
│ │ ├── ContributesBindingScopedProcessorTest.kt
│ │ ├── ContributesMockImplGeneratorTest.kt
│ │ ├── ContributesRealImplGeneratorTest.kt
│ │ ├── ContributesRendererProcessorTest.kt
│ │ └── ContributesRobotGeneratorTest.kt
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── inject/
│ └── mock/
│ ├── ContributesMockImpl.kt
│ ├── ContributesRealImpl.kt
│ ├── MockMode.kt
│ └── RealImpl.kt
├── ksp-common/
│ ├── public/
│ │ ├── build.gradle
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── ksp/
│ │ ├── CompositeSymbolProcessor.kt
│ │ ├── ContextAware.kt
│ │ ├── MergeScope.kt
│ │ └── Util.kt
│ └── testing/
│ ├── build.gradle
│ └── src/
│ └── main/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── ksp/
│ ├── CommonSourceCode.kt
│ └── Util.kt
├── metro/
│ ├── impl/
│ │ ├── api/
│ │ │ ├── android/
│ │ │ │ └── impl.api
│ │ │ └── desktop/
│ │ │ └── impl.api
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ ├── presenter/
│ │ │ │ └── metro/
│ │ │ │ └── PresenterCoroutineScopeGraph.kt
│ │ │ └── scope/
│ │ │ └── coroutine/
│ │ │ └── metro/
│ │ │ ├── AppScopeCoroutineScopeGraph.kt
│ │ │ ├── CoroutineDispatcherGraph.kt
│ │ │ └── IoDispatcher.kt
│ │ ├── noWasmJsMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── scope/
│ │ │ └── coroutine/
│ │ │ └── metro/
│ │ │ └── IoDispatcher.kt
│ │ └── wasmJsMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── scope/
│ │ └── coroutine/
│ │ └── metro/
│ │ └── IoDispatcher.kt
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ ├── inject/
│ │ │ └── metro/
│ │ │ └── ContributesScoped.kt
│ │ ├── renderer/
│ │ │ └── metro/
│ │ │ ├── RendererKey.kt
│ │ │ └── RobotKey.kt
│ │ └── scope/
│ │ └── di/
│ │ └── metro/
│ │ └── MetroService.kt
│ └── commonTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── scope/
│ └── di/
│ └── metro/
│ └── MetroServiceTest.kt
├── metro-extensions/
│ └── contribute/
│ ├── impl-code-generators/
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── metro/
│ │ │ ├── MetroContextAware.kt
│ │ │ ├── MetroExtensionSymbolProcessorProvider.kt
│ │ │ ├── Util.kt
│ │ │ └── processor/
│ │ │ ├── ContributesRendererProcessor.kt
│ │ │ ├── ContributesRobotProcessor.kt
│ │ │ └── ContributesScopedProcessor.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ ├── app/
│ │ │ └── platform/
│ │ │ └── inject/
│ │ │ └── metro/
│ │ │ ├── CommonSourceCode.kt
│ │ │ ├── Compilation.kt
│ │ │ ├── CompilerTestUtil.kt
│ │ │ └── processor/
│ │ │ ├── ContributesRendererProcessorTest.kt
│ │ │ ├── ContributesRobotGeneratorTest.kt
│ │ │ └── ContributesScopedProcessorTest.kt
│ │ └── test/
│ │ ├── TestRendererGraph.kt
│ │ └── TestRobotGraph.kt
│ └── impl-compiler-plugin/
│ ├── build.gradle
│ └── src/
│ ├── main/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── metro/
│ │ └── compiler/
│ │ ├── AppPlatformMetroExtensionsPluginComponentRegistrar.kt
│ │ ├── AppPlatformMetroExtensionsPluginRegistrar.kt
│ │ ├── ClassIds.kt
│ │ ├── Keys.kt
│ │ ├── fir/
│ │ │ ├── AppPlatformMetroExtensionsDiagnostics.kt
│ │ │ ├── AppPlatformMetroExtensionsFirCheckers.kt
│ │ │ ├── FirHelpers.kt
│ │ │ └── TypeResolution.kt
│ │ ├── renderer/
│ │ │ ├── ContributesRendererChecker.kt
│ │ │ ├── ContributesRendererFir.kt
│ │ │ ├── ContributesRendererIds.kt
│ │ │ ├── ContributesRendererIrExtension.kt
│ │ │ ├── ContributesRendererMetroExtension.kt
│ │ │ └── ContributesRendererSupport.kt
│ │ ├── robot/
│ │ │ ├── ContributesRobotChecker.kt
│ │ │ ├── ContributesRobotFir.kt
│ │ │ ├── ContributesRobotIds.kt
│ │ │ ├── ContributesRobotIrExtension.kt
│ │ │ └── ContributesRobotMetroExtension.kt
│ │ └── scoped/
│ │ ├── ContributesScopedChecker.kt
│ │ ├── ContributesScopedFir.kt
│ │ ├── ContributesScopedIds.kt
│ │ ├── ContributesScopedMetroExtension.kt
│ │ └── ContributesScopedSupport.kt
│ └── test/
│ ├── java/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── metro/
│ │ └── compiler/
│ │ └── runners/
│ │ ├── BoxTestGenerated.java
│ │ ├── FirDiagnosticTestGenerated.java
│ │ └── FirDumpTestGenerated.java
│ ├── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── metro/
│ │ └── compiler/
│ │ ├── GenerateTests.kt
│ │ ├── runners/
│ │ │ ├── AbstractBoxTest.kt
│ │ │ ├── AbstractFirDiagnosticTest.kt
│ │ │ └── AbstractFirDumpTest.kt
│ │ ├── services/
│ │ │ ├── CompilerPluginTestSupport.kt
│ │ │ ├── KotlinTestImportsPreprocessor.kt
│ │ │ ├── MetroImportsPreprocessor.kt
│ │ │ ├── MetroRuntimeProvider.kt
│ │ │ ├── PluginAnnotationsProvider.kt
│ │ │ └── TestSupportClasspathProvider.kt
│ │ └── support/
│ │ └── UnusedRendererFactory.kt
│ └── resources/
│ ├── box/
│ │ ├── contributesrenderer/
│ │ │ ├── defaultConstructorRenderer.kt
│ │ │ ├── explicitModelType.kt
│ │ │ ├── inferredFromHierarchy.kt
│ │ │ ├── inferredFromHierarchyMultipleLevels.kt
│ │ │ ├── injectConstructorRenderer.kt
│ │ │ ├── innerModel.kt
│ │ │ ├── innerRenderer.kt
│ │ │ ├── sealedHierarchy.kt
│ │ │ ├── sealedHierarchyDisabled.kt
│ │ │ └── sealedHierarchyInDependencyModule.kt
│ │ ├── contributesrobot/
│ │ │ ├── defaultConstructorRobot.kt
│ │ │ ├── indirectSupertype.kt
│ │ │ └── injectConstructorRobot.kt
│ │ └── contributesscoped/
│ │ ├── defaultScoped.kt
│ │ ├── innerClass.kt
│ │ ├── onlyScoped.kt
│ │ └── transitiveScoped.kt
│ ├── diagnostics/
│ │ ├── contributesrenderer/
│ │ │ ├── missingInjectOnNonZeroArgConstructor.fir.diag.txt
│ │ │ ├── missingInjectOnNonZeroArgConstructor.kt
│ │ │ ├── modelTypeMustBeExplicitWhenNotInferable.fir.diag.txt
│ │ │ ├── modelTypeMustBeExplicitWhenNotInferable.kt
│ │ │ ├── redundantInjectOnZeroArgConstructor.fir.diag.txt
│ │ │ ├── redundantInjectOnZeroArgConstructor.kt
│ │ │ ├── rendererMustNotBeSingleton.fir.diag.txt
│ │ │ └── rendererMustNotBeSingleton.kt
│ │ ├── contributesrobot/
│ │ │ ├── classMustImplementRobot.fir.diag.txt
│ │ │ ├── classMustImplementRobot.kt
│ │ │ ├── classWithConstructorParametersMustUseInject.fir.diag.txt
│ │ │ ├── classWithConstructorParametersMustUseInject.kt
│ │ │ ├── onlyAppScopeSupported.fir.diag.txt
│ │ │ ├── onlyAppScopeSupported.kt
│ │ │ ├── robotMustNotBeSingleton.fir.diag.txt
│ │ │ └── robotMustNotBeSingleton.kt
│ │ └── contributesscoped/
│ │ ├── multipleOtherSupertypes.fir.diag.txt
│ │ ├── multipleOtherSupertypes.kt
│ │ ├── mustBeInject.fir.diag.txt
│ │ ├── mustBeInject.kt
│ │ ├── mustImplementScoped.fir.diag.txt
│ │ ├── mustImplementScoped.kt
│ │ ├── noSupertypes.fir.diag.txt
│ │ ├── noSupertypes.kt
│ │ ├── useContributesScopedInsteadOfContributesBinding.fir.diag.txt
│ │ └── useContributesScopedInsteadOfContributesBinding.kt
│ └── dump/
│ ├── contributesrenderer/
│ │ ├── defaultConstructorRenderer.fir.txt
│ │ ├── defaultConstructorRenderer.kt
│ │ ├── defaultConstructorRendererIr.fir.kt.txt
│ │ ├── defaultConstructorRendererIr.fir.txt
│ │ └── defaultConstructorRendererIr.kt
│ ├── contributesrobot/
│ │ ├── defaultConstructorRobot.fir.txt
│ │ ├── defaultConstructorRobot.kt
│ │ ├── defaultConstructorRobotIr.fir.kt.txt
│ │ ├── defaultConstructorRobotIr.fir.txt
│ │ └── defaultConstructorRobotIr.kt
│ └── contributesscoped/
│ ├── defaultScoped.fir.txt
│ └── defaultScoped.kt
├── mkdocs.yml
├── presenter/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── presenter/
│ │ ├── BaseModel.kt
│ │ ├── Presenter.kt
│ │ ├── StateIn.kt
│ │ └── template/
│ │ ├── ModelDelegate.kt
│ │ └── Template.kt
│ └── commonTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── presenter/
│ ├── StateInTest.kt
│ └── template/
│ └── TemplateTest.kt
├── presenter-molecule/
│ ├── impl/
│ │ ├── api/
│ │ │ ├── android/
│ │ │ │ └── impl.api
│ │ │ └── desktop/
│ │ │ └── impl.api
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ └── AndroidMoleculeScopeFactory.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ ├── DefaultMoleculeScopeFactory.kt
│ │ │ └── backgesture/
│ │ │ └── DefaultBackGestureDispatcherPresenter.kt
│ │ ├── commonTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ ├── DefaultMoleculeScopeFactoryTest.kt
│ │ │ ├── KotlinInjectInjectionTest.kt
│ │ │ └── MetroInjectionTest.kt
│ │ ├── desktopMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ └── DesktopMoleculeScopeFactory.kt
│ │ ├── iosMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ └── IosMoleculeScopeFactory.kt
│ │ ├── linuxMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ └── LinuxMoleculeScopeFactory.kt
│ │ └── wasmJsMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── presenter/
│ │ └── molecule/
│ │ └── WasmJsMoleculeScopeFactory.kt
│ ├── public/
│ │ ├── api/
│ │ │ ├── android/
│ │ │ │ └── public.api
│ │ │ └── desktop/
│ │ │ └── public.api
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── presenter/
│ │ │ └── molecule/
│ │ │ ├── LaunchMoleculePresenter.kt
│ │ │ ├── MoleculePresenter.kt
│ │ │ ├── MoleculeScope.kt
│ │ │ ├── MoleculeScopeFactory.kt
│ │ │ ├── ReturningCompositionLocalProvider.kt
│ │ │ └── backgesture/
│ │ │ ├── BackEventPresenter.kt
│ │ │ ├── BackGestureDispatcherPresenter.kt
│ │ │ └── CommonBackGestureDispatcherPresenter.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── presenter/
│ │ └── molecule/
│ │ ├── LaunchMoleculePresenterTest.kt
│ │ ├── MoleculeScopeTest.kt
│ │ ├── OnEventMemoizationTest.kt
│ │ └── backgesture/
│ │ └── CommonBackGestureDispatcherPresenterTest.kt
│ └── testing/
│ ├── api/
│ │ ├── android/
│ │ │ └── testing.api
│ │ └── desktop/
│ │ └── testing.api
│ ├── build.gradle
│ └── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── presenter/
│ │ └── molecule/
│ │ ├── FakeMoleculeScopeFactory.kt
│ │ ├── TestMoleculeScope.kt
│ │ ├── TestPresenter.kt
│ │ └── backgesture/
│ │ └── TestBackGestureDispatcherPresenter.kt
│ └── commonTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── presenter/
│ └── molecule/
│ ├── FakeMoleculeScopeFactoryTest.kt
│ ├── TestMoleculeScopeTest.kt
│ ├── TestPresenterTest.kt
│ └── backgesture/
│ └── TestBackGestureDispatcherPresenterTest.kt
├── recipes/
│ ├── app/
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── androidMain/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── recipes/
│ │ │ ├── AndroidAppComponent.kt
│ │ │ ├── AndroidApplication.kt
│ │ │ ├── MainActivity.kt
│ │ │ └── MainActivityViewModel.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── recipes/
│ │ │ ├── AppComponent.kt
│ │ │ ├── DemoApplication.kt
│ │ │ └── TemplateProvider.kt
│ │ ├── desktopMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── recipes/
│ │ │ ├── DesktopApp.kt
│ │ │ ├── DesktopAppComponent.kt
│ │ │ └── Main.kt
│ │ ├── iosMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── recipes/
│ │ │ ├── IosAppComponent.kt
│ │ │ └── MainViewController.kt
│ │ └── wasmJsMain/
│ │ ├── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── recipes/
│ │ │ ├── Main.kt
│ │ │ └── WasmJsAppComponent.kt
│ │ └── resources/
│ │ ├── index.html
│ │ └── styles.css
│ ├── common/
│ │ └── impl/
│ │ ├── build.gradle
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── recipes/
│ │ ├── appbar/
│ │ │ ├── AppBarConfig.kt
│ │ │ ├── AppBarConfigModel.kt
│ │ │ └── menu/
│ │ │ └── MenuPresenter.kt
│ │ ├── backstack/
│ │ │ ├── CrossSlideBackstackPresenter.kt
│ │ │ ├── CrossSlideBackstackRenderer.kt
│ │ │ ├── PresenterBackstackScope.kt
│ │ │ └── presenter/
│ │ │ └── BackstackChildPresenter.kt
│ │ ├── landing/
│ │ │ ├── LandingPresenter.kt
│ │ │ └── LandingRenderer.kt
│ │ ├── nav3/
│ │ │ ├── Navigation3ChildPresenter.kt
│ │ │ ├── Navigation3ChildRenderer.kt
│ │ │ ├── Navigation3HomePresenter.kt
│ │ │ └── Navigation3HomeRenderer.kt
│ │ ├── saveable/
│ │ │ └── ReturningSaveableStateHolder.kt
│ │ ├── swiftui/
│ │ │ ├── SwiftUiChildPresenter.kt
│ │ │ └── SwiftUiHomePresenter.kt
│ │ └── template/
│ │ ├── RecipesAppTemplate.kt
│ │ ├── RootPresenter.kt
│ │ └── RootPresenterRenderer.kt
│ └── recipesIosApp/
│ ├── recipesIosApp/
│ │ ├── Assets.xcassets/
│ │ │ ├── AccentColor.colorset/
│ │ │ │ └── Contents.json
│ │ │ ├── AppIcon.appiconset/
│ │ │ │ └── Contents.json
│ │ │ └── Contents.json
│ │ ├── ContentView.swift
│ │ ├── Info.plist
│ │ ├── PresenterViews/
│ │ │ ├── AppPlatform+Extensions.swift
│ │ │ ├── MoleculePresenterWrapper.swift
│ │ │ ├── PresenterView.swift
│ │ │ └── PresenterViewModel.swift
│ │ ├── RecipesIosApp.swift
│ │ └── SwiftUI/
│ │ ├── SwiftUiChildPresenterView.swift
│ │ ├── SwiftUiHomePresenterBuilder.swift
│ │ ├── SwiftUiHomePresenterView.swift
│ │ └── SwiftUiRootPresenterView.swift
│ └── recipesIosApp.xcodeproj/
│ └── project.pbxproj
├── renderer/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── renderer/
│ │ ├── BaseRendererFactory.kt
│ │ ├── Renderer.kt
│ │ ├── RendererComponent.kt
│ │ ├── RendererFactory.kt
│ │ ├── RendererGraph.kt
│ │ └── RendererScope.kt
│ └── commonTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── renderer/
│ └── BaseRendererFactoryTest.kt
├── renderer-android-view/
│ └── public/
│ ├── api/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── androidInstrumentedTest/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ ├── presenter/
│ │ │ │ └── molecule/
│ │ │ │ └── backgesture/
│ │ │ │ └── ForwardBackPressEventsToPresentersAndroidTest.kt
│ │ │ └── renderer/
│ │ │ ├── AndroidRendererFactoryTest.kt
│ │ │ ├── RecyclerViewViewHolderRendererTest.kt
│ │ │ ├── TestActivity.kt
│ │ │ ├── TestApplication.kt
│ │ │ ├── ViewBindingRendererTest.kt
│ │ │ └── ViewRendererTest.kt
│ │ └── res/
│ │ └── layout/
│ │ └── viewbinding_layout.xml
│ └── androidMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ ├── presenter/
│ │ └── molecule/
│ │ └── backgesture/
│ │ └── BackGestureDispatcherPresenterAndroid.kt
│ └── renderer/
│ ├── AndroidRendererFactory.kt
│ ├── BaseAndroidViewRenderer.kt
│ ├── RecyclerViewViewHolderRenderer.kt
│ ├── ViewBindingRenderer.kt
│ ├── ViewRenderer.kt
│ └── template/
│ └── AndroidTemplateRenderer.kt
├── renderer-compose-multiplatform/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── androidInstrumentedTest/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ ├── presenter/
│ │ │ └── molecule/
│ │ │ └── backgesture/
│ │ │ └── ForwardBackPressEventsToPresentersComposeTest.kt
│ │ └── renderer/
│ │ ├── ComposeAndroidRendererFactoryDeviceTest.kt
│ │ ├── TestActivity.kt
│ │ └── TestApplication.kt
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── renderer/
│ │ ├── AndroidViewWithinComposeRenderer.kt
│ │ ├── BaseComposeWithinAndroidViewRenderer.kt
│ │ ├── ComposeAndroidRendererFactory.kt
│ │ └── ComposeWithinAndroidViewRenderer.kt
│ ├── androidUnitTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── renderer/
│ │ └── ComposeAndroidRendererFactoryTest.kt
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ ├── presenter/
│ │ │ └── molecule/
│ │ │ └── backgesture/
│ │ │ └── BackGestureDispatcherPresenterCompose.kt
│ │ └── renderer/
│ │ ├── BaseComposeRenderer.kt
│ │ ├── ComposeRenderer.kt
│ │ └── ComposeRendererFactory.kt
│ └── desktopTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── renderer/
│ ├── ComposeRendererFactoryTest.kt
│ └── ComposeRendererTest.kt
├── robot/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ ├── AndroidViewRobot.kt
│ │ ├── DefaultRootMatcherProvider.kt
│ │ └── RootMatcherProvider.kt
│ ├── androidUnitTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── WaiterTest.kt
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ ├── Robot.kt
│ │ ├── RobotComponent.kt
│ │ ├── RobotGraph.kt
│ │ └── internal/
│ │ └── RobotInternals.kt
│ ├── commonTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── RobotTest.kt
│ └── noWasmJsMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── robot/
│ └── Waiter.kt
├── robot-compose-multiplatform/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── appleAndDesktopTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── ComposeRobotTest.kt
│ └── commonMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── robot/
│ ├── ComposeInteractionsProvider.kt
│ └── ComposeRobot.kt
├── robot-internal/
│ └── public/
│ ├── api/
│ │ ├── android/
│ │ │ └── public.api
│ │ └── desktop/
│ │ └── public.api
│ ├── build.gradle
│ └── src/
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── internal/
│ │ └── DefaultRootScope.kt
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── internal/
│ │ ├── DefaultRootScope.kt
│ │ ├── RootScope.kt
│ │ └── RootScopeProvider.kt
│ ├── commonTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── internal/
│ │ └── RootScopeTest.kt
│ ├── desktopMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── internal/
│ │ └── DefaultRootScope.kt
│ ├── nativeMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── robot/
│ │ └── internal/
│ │ └── DefaultRootScope.kt
│ └── wasmJsMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── robot/
│ └── internal/
│ └── DefaultRootScope.kt
├── sample/
│ ├── app/
│ │ ├── build.gradle
│ │ ├── lint.xml
│ │ └── src/
│ │ ├── androidInstrumentedTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ ├── AndroidLoginUiTest.kt
│ │ │ ├── TestAndroidAppGraph.kt
│ │ │ ├── TestAndroidApplication.kt
│ │ │ └── TestRunner.kt
│ │ ├── androidMain/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── kotlin/
│ │ │ │ └── software/
│ │ │ │ └── amazon/
│ │ │ │ └── app/
│ │ │ │ └── platform/
│ │ │ │ └── sample/
│ │ │ │ ├── AndroidAppGraph.kt
│ │ │ │ ├── AndroidApplication.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── MainActivityViewModel.kt
│ │ │ └── res/
│ │ │ ├── drawable/
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ └── ic_launcher_foreground.xml
│ │ │ ├── mipmap-anydpi/
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ └── values/
│ │ │ └── strings.xml
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ ├── AppGraph.kt
│ │ │ ├── DemoApplication.kt
│ │ │ └── TemplateProvider.kt
│ │ ├── desktopMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ ├── DesktopApp.kt
│ │ │ ├── DesktopAppGraph.kt
│ │ │ └── Main.kt
│ │ ├── desktopTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ ├── LoginUiTest.kt
│ │ │ ├── TestAnimationHelper.kt
│ │ │ └── TestDesktopAppGraph.kt
│ │ ├── iosMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ ├── IosAppGraph.kt
│ │ │ └── MainViewController.kt
│ │ └── wasmJsMain/
│ │ ├── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ ├── Main.kt
│ │ │ └── WasmJsAppGraph.kt
│ │ └── resources/
│ │ ├── index.html
│ │ └── styles.css
│ ├── iosApp/
│ │ ├── Configuration/
│ │ │ └── Config.xcconfig
│ │ ├── iosApp/
│ │ │ ├── Assets.xcassets/
│ │ │ │ ├── AccentColor.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset/
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── ComposeContentView.swift
│ │ │ ├── Info.plist
│ │ │ ├── Preview Content/
│ │ │ │ └── Preview Assets.xcassets/
│ │ │ │ └── Contents.json
│ │ │ └── iOSApp.swift
│ │ └── iosApp.xcodeproj/
│ │ └── project.pbxproj
│ ├── login/
│ │ ├── impl/
│ │ │ ├── build.gradle
│ │ │ └── src/
│ │ │ ├── appleAndDesktopTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── software/
│ │ │ │ └── amazon/
│ │ │ │ └── app/
│ │ │ │ └── platform/
│ │ │ │ └── sample/
│ │ │ │ └── login/
│ │ │ │ └── LoginRendererTest.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── software/
│ │ │ │ └── amazon/
│ │ │ │ └── app/
│ │ │ │ └── platform/
│ │ │ │ └── sample/
│ │ │ │ └── login/
│ │ │ │ ├── LoginPresenterImpl.kt
│ │ │ │ └── LoginRenderer.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── login/
│ │ │ └── LoginPresenterImplTest.kt
│ │ ├── impl-robots/
│ │ │ ├── build.gradle
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── login/
│ │ │ └── LoginRobot.kt
│ │ └── public/
│ │ ├── build.gradle
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── sample/
│ │ └── login/
│ │ └── LoginPresenter.kt
│ ├── navigation/
│ │ ├── impl/
│ │ │ ├── build.gradle
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── software/
│ │ │ │ └── amazon/
│ │ │ │ └── app/
│ │ │ │ └── platform/
│ │ │ │ └── sample/
│ │ │ │ └── navigation/
│ │ │ │ └── NavigationPresenterImpl.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── navigation/
│ │ │ └── NavigationPresenterImplTest.kt
│ │ └── public/
│ │ ├── build.gradle
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── sample/
│ │ └── navigation/
│ │ └── NavigationPresenter.kt
│ ├── templates/
│ │ ├── impl/
│ │ │ ├── build.gradle
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ ├── kotlin/
│ │ │ │ │ └── software/
│ │ │ │ │ └── amazon/
│ │ │ │ │ └── app/
│ │ │ │ │ └── platform/
│ │ │ │ │ └── sample/
│ │ │ │ │ └── template/
│ │ │ │ │ └── AndroidSampleAppTemplateRenderer.kt
│ │ │ │ └── res/
│ │ │ │ └── layout/
│ │ │ │ └── sample_app_template_root.xml
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── template/
│ │ │ └── ComposeSampleAppTemplateRenderer.kt
│ │ └── public/
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── template/
│ │ │ ├── SampleAppTemplate.kt
│ │ │ ├── SampleAppTemplatePresenter.kt
│ │ │ └── animation/
│ │ │ └── AnimationContentKey.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── sample/
│ │ └── template/
│ │ └── SampleAppTemplatePresenterTest.kt
│ └── user/
│ ├── impl/
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── user/
│ │ │ └── AndroidAnimationsHelper.kt
│ │ ├── appleAndDesktopMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── user/
│ │ │ └── DefaultAnimationsHelper.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── user/
│ │ │ ├── AnimationHelper.kt
│ │ │ ├── SessionTimeout.kt
│ │ │ ├── UserGraph.kt
│ │ │ ├── UserImpl.kt
│ │ │ ├── UserManagerImpl.kt
│ │ │ ├── UserPageDetailPresenter.kt
│ │ │ ├── UserPageDetailRenderer.kt
│ │ │ ├── UserPageListPresenter.kt
│ │ │ ├── UserPageListRenderer.kt
│ │ │ └── UserPagePresenterImpl.kt
│ │ ├── commonTest/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── sample/
│ │ │ └── user/
│ │ │ ├── FakeAnimationHelper.kt
│ │ │ ├── SessionTimeoutTest.kt
│ │ │ ├── UserManagerImplTest.kt
│ │ │ └── UserPagePresenterImplTest.kt
│ │ └── wasmJsMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── sample/
│ │ └── user/
│ │ └── DefaultAnimationsHelper.kt
│ ├── impl-robots/
│ │ ├── build.gradle
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── sample/
│ │ └── user/
│ │ └── UserPageRobot.kt
│ ├── public/
│ │ ├── build.gradle
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── sample/
│ │ └── user/
│ │ ├── User.kt
│ │ ├── UserManager.kt
│ │ ├── UserPagePresenter.kt
│ │ └── UserScope.kt
│ └── testing/
│ ├── build.gradle
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── sample/
│ └── user/
│ ├── FakeUser.kt
│ └── FakeUserManager.kt
├── scope/
│ ├── public/
│ │ ├── api/
│ │ │ ├── android/
│ │ │ │ └── public.api
│ │ │ └── desktop/
│ │ │ └── public.api
│ │ ├── build.gradle
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── software/
│ │ │ └── amazon/
│ │ │ └── app/
│ │ │ └── platform/
│ │ │ └── scope/
│ │ │ ├── RootScopeProvider.kt
│ │ │ ├── Scope.kt
│ │ │ ├── ScopeImpl.kt
│ │ │ ├── Scoped.kt
│ │ │ └── coroutine/
│ │ │ ├── CoroutineScopeScoped.kt
│ │ │ └── CoroutineScopeService.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── scope/
│ │ ├── ScopeTest.kt
│ │ └── coroutine/
│ │ ├── CoroutineScopeScopedTest.kt
│ │ └── CoroutineScopeServiceTest.kt
│ └── testing/
│ ├── api/
│ │ ├── android/
│ │ │ └── testing.api
│ │ └── desktop/
│ │ └── testing.api
│ ├── build.gradle
│ └── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── software/
│ │ └── amazon/
│ │ └── app/
│ │ └── platform/
│ │ └── scope/
│ │ ├── RunTestWithScope.kt
│ │ └── TestScope.kt
│ └── commonTest/
│ └── kotlin/
│ └── software/
│ └── amazon/
│ └── app/
│ └── platform/
│ └── scope/
│ ├── RunTestWithScopeTest.kt
│ └── TestScopeTest.kt
└── settings.gradle
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
ij_continuation_indent_size = 8
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = true
ij_smart_tabs = false
ij_visual_guides = none
ij_wrap_on_typing = false
[*.java]
ij_java_align_consecutive_assignments = false
ij_java_align_consecutive_variable_declarations = false
ij_java_align_group_field_declarations = false
ij_java_align_multiline_annotation_parameters = false
ij_java_align_multiline_array_initializer_expression = false
ij_java_align_multiline_assignment = false
ij_java_align_multiline_binary_operation = false
ij_java_align_multiline_chained_methods = false
ij_java_align_multiline_deconstruction_list_components = true
ij_java_align_multiline_extends_list = false
ij_java_align_multiline_for = true
ij_java_align_multiline_method_parentheses = false
ij_java_align_multiline_parameters = true
ij_java_align_multiline_parameters_in_calls = false
ij_java_align_multiline_parenthesized_expression = false
ij_java_align_multiline_records = true
ij_java_align_multiline_resources = true
ij_java_align_multiline_ternary_operation = false
ij_java_align_multiline_text_blocks = false
ij_java_align_multiline_throws_list = false
ij_java_align_subsequent_simple_methods = false
ij_java_align_throws_keyword = false
ij_java_align_types_in_multi_catch = true
ij_java_annotation_parameter_wrap = off
ij_java_array_initializer_new_line_after_left_brace = false
ij_java_array_initializer_right_brace_on_new_line = false
ij_java_array_initializer_wrap = off
ij_java_assert_statement_colon_on_next_line = false
ij_java_assert_statement_wrap = off
ij_java_assignment_wrap = off
ij_java_binary_operation_sign_on_next_line = true
ij_java_binary_operation_wrap = off
ij_java_blank_lines_after_anonymous_class_header = 0
ij_java_blank_lines_after_class_header = 0
ij_java_blank_lines_after_imports = 1
ij_java_blank_lines_after_package = 1
ij_java_blank_lines_around_class = 1
ij_java_blank_lines_around_field = 0
ij_java_blank_lines_around_field_in_interface = 0
ij_java_blank_lines_around_initializer = 1
ij_java_blank_lines_around_method = 1
ij_java_blank_lines_around_method_in_interface = 1
ij_java_blank_lines_before_class_end = 0
ij_java_blank_lines_before_imports = 1
ij_java_blank_lines_before_method_body = 0
ij_java_blank_lines_before_package = 0
ij_java_block_brace_style = end_of_line
ij_java_block_comment_add_space = false
ij_java_block_comment_at_first_column = true
ij_java_builder_methods = none
ij_java_call_parameters_new_line_after_left_paren = false
ij_java_call_parameters_right_paren_on_new_line = false
ij_java_call_parameters_wrap = off
ij_java_case_statement_on_separate_line = true
ij_java_catch_on_new_line = false
ij_java_class_annotation_wrap = split_into_lines
ij_java_class_brace_style = end_of_line
ij_java_class_count_to_use_import_on_demand = 99
ij_java_class_names_in_javadoc = 3
ij_java_deconstruction_list_wrap = normal
ij_java_do_not_indent_top_level_class_members = false
ij_java_do_not_wrap_after_single_annotation = false
ij_java_do_not_wrap_after_single_annotation_in_parameter = false
ij_java_do_while_brace_force = never
ij_java_doc_add_blank_line_after_description = true
ij_java_doc_add_blank_line_after_param_comments = false
ij_java_doc_add_blank_line_after_return = false
ij_java_doc_add_p_tag_on_empty_lines = false
ij_java_doc_align_exception_comments = true
ij_java_doc_align_param_comments = true
ij_java_doc_do_not_wrap_if_one_line = false
ij_java_doc_enable_formatting = true
ij_java_doc_enable_leading_asterisks = true
ij_java_doc_indent_on_continuation = false
ij_java_doc_keep_empty_lines = true
ij_java_doc_keep_empty_parameter_tag = true
ij_java_doc_keep_empty_return_tag = true
ij_java_doc_keep_empty_throws_tag = true
ij_java_doc_keep_invalid_tags = true
ij_java_doc_param_description_on_new_line = false
ij_java_doc_preserve_line_breaks = false
ij_java_doc_use_throws_not_exception_tag = true
ij_java_else_on_new_line = false
ij_java_enum_constants_wrap = off
ij_java_extends_keyword_wrap = off
ij_java_extends_list_wrap = off
ij_java_field_annotation_wrap = split_into_lines
ij_java_field_name_prefix = m
ij_java_finally_on_new_line = false
ij_java_for_brace_force = never
ij_java_for_statement_new_line_after_left_paren = false
ij_java_for_statement_right_paren_on_new_line = false
ij_java_for_statement_wrap = off
ij_java_generate_final_locals = false
ij_java_generate_final_parameters = false
ij_java_if_brace_force = never
ij_java_imports_layout = android.**,|,com.**,|,junit.**,|,net.**,|,org.**,|,java.**,|,javax.**,|,*,|,$*,|
ij_java_indent_case_from_switch = true
ij_java_insert_inner_class_imports = false
ij_java_insert_override_annotation = true
ij_java_keep_blank_lines_before_right_brace = 2
ij_java_keep_blank_lines_between_package_declaration_and_header = 2
ij_java_keep_blank_lines_in_code = 2
ij_java_keep_blank_lines_in_declarations = 2
ij_java_keep_builder_methods_indents = false
ij_java_keep_control_statement_in_one_line = true
ij_java_keep_first_column_comment = true
ij_java_keep_indents_on_empty_lines = false
ij_java_keep_line_breaks = true
ij_java_keep_multiple_expressions_in_one_line = false
ij_java_keep_simple_blocks_in_one_line = false
ij_java_keep_simple_classes_in_one_line = false
ij_java_keep_simple_lambdas_in_one_line = false
ij_java_keep_simple_methods_in_one_line = false
ij_java_label_indent_absolute = false
ij_java_label_indent_size = 0
ij_java_lambda_brace_style = end_of_line
ij_java_layout_static_imports_separately = true
ij_java_line_comment_add_space = false
ij_java_line_comment_add_space_on_reformat = false
ij_java_line_comment_at_first_column = true
ij_java_method_annotation_wrap = split_into_lines
ij_java_method_brace_style = end_of_line
ij_java_method_call_chain_wrap = off
ij_java_method_parameters_new_line_after_left_paren = false
ij_java_method_parameters_right_paren_on_new_line = false
ij_java_method_parameters_wrap = off
ij_java_modifier_list_wrap = false
ij_java_multi_catch_types_wrap = normal
ij_java_names_count_to_use_import_on_demand = 99
ij_java_new_line_after_lparen_in_annotation = false
ij_java_new_line_after_lparen_in_deconstruction_pattern = true
ij_java_new_line_after_lparen_in_record_header = false
ij_java_parameter_annotation_wrap = off
ij_java_parentheses_expression_new_line_after_left_paren = false
ij_java_parentheses_expression_right_paren_on_new_line = false
ij_java_place_assignment_sign_on_next_line = false
ij_java_prefer_longer_names = true
ij_java_prefer_parameters_wrap = false
ij_java_record_components_wrap = normal
ij_java_repeat_synchronized = true
ij_java_replace_instanceof_and_cast = false
ij_java_replace_null_check = true
ij_java_replace_sum_lambda_with_method_ref = true
ij_java_resource_list_new_line_after_left_paren = false
ij_java_resource_list_right_paren_on_new_line = false
ij_java_resource_list_wrap = off
ij_java_rparen_on_new_line_in_annotation = false
ij_java_rparen_on_new_line_in_deconstruction_pattern = true
ij_java_rparen_on_new_line_in_record_header = false
ij_java_space_after_closing_angle_bracket_in_type_argument = false
ij_java_space_after_colon = true
ij_java_space_after_comma = true
ij_java_space_after_comma_in_type_arguments = true
ij_java_space_after_for_semicolon = true
ij_java_space_after_quest = true
ij_java_space_after_type_cast = true
ij_java_space_before_annotation_array_initializer_left_brace = false
ij_java_space_before_annotation_parameter_list = false
ij_java_space_before_array_initializer_left_brace = false
ij_java_space_before_catch_keyword = true
ij_java_space_before_catch_left_brace = true
ij_java_space_before_catch_parentheses = true
ij_java_space_before_class_left_brace = true
ij_java_space_before_colon = true
ij_java_space_before_colon_in_foreach = true
ij_java_space_before_comma = false
ij_java_space_before_deconstruction_list = false
ij_java_space_before_do_left_brace = true
ij_java_space_before_else_keyword = true
ij_java_space_before_else_left_brace = true
ij_java_space_before_finally_keyword = true
ij_java_space_before_finally_left_brace = true
ij_java_space_before_for_left_brace = true
ij_java_space_before_for_parentheses = true
ij_java_space_before_for_semicolon = false
ij_java_space_before_if_left_brace = true
ij_java_space_before_if_parentheses = true
ij_java_space_before_method_call_parentheses = false
ij_java_space_before_method_left_brace = true
ij_java_space_before_method_parentheses = false
ij_java_space_before_opening_angle_bracket_in_type_parameter = false
ij_java_space_before_quest = true
ij_java_space_before_switch_left_brace = true
ij_java_space_before_switch_parentheses = true
ij_java_space_before_synchronized_left_brace = true
ij_java_space_before_synchronized_parentheses = true
ij_java_space_before_try_left_brace = true
ij_java_space_before_try_parentheses = true
ij_java_space_before_type_parameter_list = false
ij_java_space_before_while_keyword = true
ij_java_space_before_while_left_brace = true
ij_java_space_before_while_parentheses = true
ij_java_space_inside_one_line_enum_braces = false
ij_java_space_within_empty_array_initializer_braces = false
ij_java_space_within_empty_method_call_parentheses = false
ij_java_space_within_empty_method_parentheses = false
ij_java_spaces_around_additive_operators = true
ij_java_spaces_around_annotation_eq = true
ij_java_spaces_around_assignment_operators = true
ij_java_spaces_around_bitwise_operators = true
ij_java_spaces_around_equality_operators = true
ij_java_spaces_around_lambda_arrow = true
ij_java_spaces_around_logical_operators = true
ij_java_spaces_around_method_ref_dbl_colon = false
ij_java_spaces_around_multiplicative_operators = true
ij_java_spaces_around_relational_operators = true
ij_java_spaces_around_shift_operators = true
ij_java_spaces_around_type_bounds_in_type_parameters = true
ij_java_spaces_around_unary_operator = false
ij_java_spaces_within_angle_brackets = false
ij_java_spaces_within_annotation_parentheses = false
ij_java_spaces_within_array_initializer_braces = false
ij_java_spaces_within_braces = false
ij_java_spaces_within_brackets = false
ij_java_spaces_within_cast_parentheses = false
ij_java_spaces_within_catch_parentheses = false
ij_java_spaces_within_deconstruction_list = false
ij_java_spaces_within_for_parentheses = false
ij_java_spaces_within_if_parentheses = false
ij_java_spaces_within_method_call_parentheses = false
ij_java_spaces_within_method_parentheses = false
ij_java_spaces_within_parentheses = false
ij_java_spaces_within_record_header = false
ij_java_spaces_within_switch_parentheses = false
ij_java_spaces_within_synchronized_parentheses = false
ij_java_spaces_within_try_parentheses = false
ij_java_spaces_within_while_parentheses = false
ij_java_special_else_if_treatment = true
ij_java_subclass_name_suffix = Impl
ij_java_ternary_operation_signs_on_next_line = false
ij_java_ternary_operation_wrap = off
ij_java_test_name_suffix = Test
ij_java_throws_keyword_wrap = off
ij_java_throws_list_wrap = off
ij_java_use_external_annotations = false
ij_java_use_fq_class_names = false
ij_java_use_relative_indents = false
ij_java_use_single_class_imports = true
ij_java_variable_annotation_wrap = off
ij_java_visibility = public
ij_java_while_brace_force = never
ij_java_while_on_new_line = false
ij_java_wrap_comments = false
ij_java_wrap_first_method_in_call_chain = false
ij_java_wrap_long_lines = false
[*.markdown]
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
ij_markdown_force_one_space_between_words = true
ij_markdown_format_tables = true
ij_markdown_insert_quote_arrows_on_wrap = true
ij_markdown_keep_indents_on_empty_lines = false
ij_markdown_keep_line_breaks_inside_text_blocks = true
ij_markdown_max_lines_around_block_elements = 1
ij_markdown_max_lines_around_header = 1
ij_markdown_max_lines_between_paragraphs = 1
ij_markdown_min_lines_around_block_elements = 1
ij_markdown_min_lines_around_header = 1
ij_markdown_min_lines_between_paragraphs = 1
ij_markdown_wrap_text_if_long = true
ij_markdown_wrap_text_inside_blockquotes = true
[*.properties]
ij_properties_align_group_field_declarations = false
ij_properties_keep_blank_lines = false
ij_properties_key_value_delimiter = equals
ij_properties_spaces_around_key_value_delimiter = false
[.editorconfig]
ij_editorconfig_align_group_field_declarations = false
ij_editorconfig_space_after_colon = false
ij_editorconfig_space_after_comma = true
ij_editorconfig_space_before_colon = false
ij_editorconfig_space_before_comma = false
ij_editorconfig_spaces_around_assignment_operators = true
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
ij_xml_align_attributes = false
ij_xml_align_text = false
ij_xml_attribute_wrap = normal
ij_xml_block_comment_add_space = false
ij_xml_block_comment_at_first_column = true
ij_xml_keep_blank_lines = 2
ij_xml_keep_indents_on_empty_lines = false
ij_xml_keep_line_breaks = false
ij_xml_keep_line_breaks_in_text = true
ij_xml_keep_whitespaces = false
ij_xml_keep_whitespaces_around_cdata = preserve
ij_xml_keep_whitespaces_inside_cdata = false
ij_xml_line_comment_at_first_column = true
ij_xml_space_after_tag_name = false
ij_xml_space_around_equals_in_attribute = false
ij_xml_space_inside_empty_tag = true
ij_xml_text_wrap = normal
ij_xml_use_custom_settings = true
[{*.apinotes,*.yaml,*.yml,.clang-format,.clang-tidy,_clang-format}]
indent_size = 2
ij_yaml_align_values_properties = do_not_align
ij_yaml_autoinsert_sequence_marker = true
ij_yaml_block_mapping_on_new_line = false
ij_yaml_indent_sequence_value = true
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true
ij_yaml_sequence_on_new_line = false
ij_yaml_space_before_colon = false
ij_yaml_spaces_within_braces = true
ij_yaml_spaces_within_brackets = true
[{*.bash,*.sh,*.zsh}]
indent_size = 2
tab_width = 2
ij_shell_binary_ops_start_line = false
ij_shell_keep_column_alignment_padding = false
ij_shell_minify_program = false
ij_shell_redirect_followed_by_space = false
ij_shell_switch_cases_indented = false
ij_shell_use_unix_line_separator = true
[{*.c,*.c++,*.c++m,*.cc,*.ccm,*.cp,*.cpp,*.cppm,*.cu,*.cuh,*.cxx,*.cxxm,*.h,*.h++,*.hh,*.hp,*.hpp,*.hxx,*.i,*.icc,*.ii,*.inl,*.ino,*.ipp,*.ixx,*.m,*.mm,*.mxx,*.pch,*.tcc,*.tpp}]
ij_c_add_brief_tag = false
ij_c_add_getter_prefix = true
ij_c_add_setter_prefix = true
ij_c_align_dictionary_pair_values = false
ij_c_align_group_field_declarations = false
ij_c_align_init_list_in_columns = true
ij_c_align_multiline_array_initializer_expression = true
ij_c_align_multiline_assignment = true
ij_c_align_multiline_binary_operation = true
ij_c_align_multiline_chained_methods = false
ij_c_align_multiline_for = true
ij_c_align_multiline_ternary_operation = true
ij_c_array_initializer_comma_on_next_line = false
ij_c_array_initializer_new_line_after_left_brace = false
ij_c_array_initializer_right_brace_on_new_line = false
ij_c_array_initializer_wrap = normal
ij_c_assignment_wrap = off
ij_c_binary_operation_sign_on_next_line = false
ij_c_binary_operation_wrap = normal
ij_c_blank_lines_after_class_header = 0
ij_c_blank_lines_after_imports = 1
ij_c_blank_lines_around_class = 1
ij_c_blank_lines_around_field = 0
ij_c_blank_lines_around_field_in_interface = 0
ij_c_blank_lines_around_method = 1
ij_c_blank_lines_around_method_in_interface = 1
ij_c_blank_lines_around_namespace = 0
ij_c_blank_lines_around_properties_in_declaration = 0
ij_c_blank_lines_around_properties_in_interface = 0
ij_c_blank_lines_before_imports = 1
ij_c_blank_lines_before_method_body = 0
ij_c_block_brace_placement = end_of_line
ij_c_block_brace_style = end_of_line
ij_c_block_comment_at_first_column = true
ij_c_catch_on_new_line = false
ij_c_class_brace_style = end_of_line
ij_c_class_constructor_init_list_align_multiline = true
ij_c_class_constructor_init_list_comma_on_next_line = false
ij_c_class_constructor_init_list_new_line_after_colon = never
ij_c_class_constructor_init_list_new_line_before_colon = if_long
ij_c_class_constructor_init_list_wrap = normal
ij_c_copy_is_deep = false
ij_c_create_interface_for_categories = true
ij_c_declare_generated_methods = true
ij_c_description_include_member_names = true
ij_c_discharged_short_ternary_operator = false
ij_c_do_not_add_breaks = false
ij_c_do_while_brace_force = never
ij_c_else_on_new_line = false
ij_c_enum_constants_comma_on_next_line = false
ij_c_enum_constants_wrap = on_every_item
ij_c_for_brace_force = never
ij_c_for_statement_new_line_after_left_paren = false
ij_c_for_statement_right_paren_on_new_line = false
ij_c_for_statement_wrap = off
ij_c_function_brace_placement = end_of_line
ij_c_function_call_arguments_align_multiline = true
ij_c_function_call_arguments_align_multiline_pars = false
ij_c_function_call_arguments_comma_on_next_line = false
ij_c_function_call_arguments_new_line_after_lpar = false
ij_c_function_call_arguments_new_line_before_rpar = false
ij_c_function_call_arguments_wrap = normal
ij_c_function_non_top_after_return_type_wrap = normal
ij_c_function_parameters_align_multiline = true
ij_c_function_parameters_align_multiline_pars = false
ij_c_function_parameters_comma_on_next_line = false
ij_c_function_parameters_new_line_after_lpar = false
ij_c_function_parameters_new_line_before_rpar = false
ij_c_function_parameters_wrap = normal
ij_c_function_top_after_return_type_wrap = normal
ij_c_generate_additional_eq_operators = true
ij_c_generate_additional_rel_operators = true
ij_c_generate_class_constructor = true
ij_c_generate_comparison_operators_use_std_tie = false
ij_c_generate_instance_variables_for_properties = ask
ij_c_generate_operators_as_members = true
ij_c_header_guard_style_pattern = ${PROJECT_NAME}_${FILE_NAME}_${EXT}
ij_c_if_brace_force = never
ij_c_in_line_short_ternary_operator = true
ij_c_indent_block_comment = true
ij_c_indent_c_struct_members = 4
ij_c_indent_case_from_switch = true
ij_c_indent_class_members = 4
ij_c_indent_directive_as_code = false
ij_c_indent_implementation_members = 0
ij_c_indent_inside_code_block = 4
ij_c_indent_interface_members = 0
ij_c_indent_interface_members_except_ivars_block = false
ij_c_indent_namespace_members = 4
ij_c_indent_preprocessor_directive = 0
ij_c_indent_visibility_keywords = 0
ij_c_insert_override = true
ij_c_insert_virtual_with_override = false
ij_c_introduce_auto_consts = false
ij_c_introduce_auto_vars = false
ij_c_introduce_const_params = false
ij_c_introduce_const_vars = false
ij_c_introduce_constexpr_consts = false
ij_c_introduce_generate_property = false
ij_c_introduce_generate_synthesize = true
ij_c_introduce_globals_to_header = true
ij_c_introduce_prop_to_private_category = false
ij_c_introduce_static_consts = true
ij_c_introduce_use_ns_types = false
ij_c_ivars_prefix = _
ij_c_keep_blank_lines_before_end = 2
ij_c_keep_blank_lines_before_right_brace = 2
ij_c_keep_blank_lines_in_code = 2
ij_c_keep_blank_lines_in_declarations = 2
ij_c_keep_case_expressions_in_one_line = false
ij_c_keep_control_statement_in_one_line = true
ij_c_keep_directive_at_first_column = true
ij_c_keep_first_column_comment = true
ij_c_keep_line_breaks = true
ij_c_keep_nested_namespaces_in_one_line = false
ij_c_keep_simple_blocks_in_one_line = true
ij_c_keep_simple_methods_in_one_line = true
ij_c_keep_structures_in_one_line = false
ij_c_lambda_capture_list_align_multiline = false
ij_c_lambda_capture_list_align_multiline_bracket = false
ij_c_lambda_capture_list_comma_on_next_line = false
ij_c_lambda_capture_list_new_line_after_lbracket = false
ij_c_lambda_capture_list_new_line_before_rbracket = false
ij_c_lambda_capture_list_wrap = off
ij_c_line_comment_add_space = false
ij_c_line_comment_at_first_column = true
ij_c_method_brace_placement = end_of_line
ij_c_method_call_arguments_align_by_colons = true
ij_c_method_call_arguments_align_multiline = false
ij_c_method_call_arguments_special_dictionary_pairs_treatment = true
ij_c_method_call_arguments_wrap = off
ij_c_method_call_chain_wrap = off
ij_c_method_parameters_align_by_colons = true
ij_c_method_parameters_align_multiline = false
ij_c_method_parameters_wrap = off
ij_c_namespace_brace_placement = end_of_line
ij_c_parentheses_expression_new_line_after_left_paren = false
ij_c_parentheses_expression_right_paren_on_new_line = false
ij_c_place_assignment_sign_on_next_line = false
ij_c_property_nonatomic = true
ij_c_put_ivars_to_implementation = true
ij_c_refactor_compatibility_aliases_and_classes = true
ij_c_refactor_properties_and_ivars = true
ij_c_release_style = ivar
ij_c_retain_object_parameters_in_constructor = true
ij_c_semicolon_after_method_signature = false
ij_c_shift_operation_align_multiline = true
ij_c_shift_operation_wrap = normal
ij_c_show_non_virtual_functions = false
ij_c_space_after_colon = true
ij_c_space_after_colon_in_foreach = true
ij_c_space_after_colon_in_selector = false
ij_c_space_after_comma = true
ij_c_space_after_cup_in_blocks = false
ij_c_space_after_dictionary_literal_colon = true
ij_c_space_after_for_semicolon = true
ij_c_space_after_init_list_colon = true
ij_c_space_after_method_parameter_type_parentheses = false
ij_c_space_after_method_return_type_parentheses = false
ij_c_space_after_pointer_in_declaration = false
ij_c_space_after_quest = true
ij_c_space_after_reference_in_declaration = false
ij_c_space_after_reference_in_rvalue = false
ij_c_space_after_structures_rbrace = true
ij_c_space_after_superclass_colon = true
ij_c_space_after_type_cast = true
ij_c_space_after_visibility_sign_in_method_declaration = true
ij_c_space_before_autorelease_pool_lbrace = true
ij_c_space_before_catch_keyword = true
ij_c_space_before_catch_left_brace = true
ij_c_space_before_catch_parentheses = true
ij_c_space_before_category_parentheses = true
ij_c_space_before_chained_send_message = true
ij_c_space_before_class_left_brace = true
ij_c_space_before_colon = true
ij_c_space_before_colon_in_foreach = false
ij_c_space_before_comma = false
ij_c_space_before_dictionary_literal_colon = false
ij_c_space_before_do_left_brace = true
ij_c_space_before_else_keyword = true
ij_c_space_before_else_left_brace = true
ij_c_space_before_export_lbrace = true
ij_c_space_before_for_left_brace = true
ij_c_space_before_for_parentheses = true
ij_c_space_before_for_semicolon = false
ij_c_space_before_if_left_brace = true
ij_c_space_before_if_parentheses = true
ij_c_space_before_init_list = false
ij_c_space_before_init_list_colon = true
ij_c_space_before_method_call_parentheses = false
ij_c_space_before_method_left_brace = true
ij_c_space_before_method_parentheses = false
ij_c_space_before_namespace_lbrace = true
ij_c_space_before_pointer_in_declaration = true
ij_c_space_before_property_attributes_parentheses = false
ij_c_space_before_protocols_brackets = true
ij_c_space_before_quest = true
ij_c_space_before_reference_in_declaration = true
ij_c_space_before_superclass_colon = true
ij_c_space_before_switch_left_brace = true
ij_c_space_before_switch_parentheses = true
ij_c_space_before_template_call_lt = false
ij_c_space_before_template_declaration_lt = false
ij_c_space_before_try_left_brace = true
ij_c_space_before_while_keyword = true
ij_c_space_before_while_left_brace = true
ij_c_space_before_while_parentheses = true
ij_c_space_between_adjacent_brackets = false
ij_c_space_between_operator_and_punctuator = false
ij_c_space_within_empty_array_initializer_braces = false
ij_c_spaces_around_additive_operators = true
ij_c_spaces_around_assignment_operators = true
ij_c_spaces_around_bitwise_operators = true
ij_c_spaces_around_equality_operators = true
ij_c_spaces_around_lambda_arrow = true
ij_c_spaces_around_logical_operators = true
ij_c_spaces_around_multiplicative_operators = true
ij_c_spaces_around_pm_operators = false
ij_c_spaces_around_relational_operators = true
ij_c_spaces_around_shift_operators = true
ij_c_spaces_around_unary_operator = false
ij_c_spaces_within_array_initializer_braces = false
ij_c_spaces_within_braces = true
ij_c_spaces_within_brackets = false
ij_c_spaces_within_cast_parentheses = false
ij_c_spaces_within_catch_parentheses = false
ij_c_spaces_within_category_parentheses = false
ij_c_spaces_within_empty_braces = false
ij_c_spaces_within_empty_function_call_parentheses = false
ij_c_spaces_within_empty_function_declaration_parentheses = false
ij_c_spaces_within_empty_lambda_capture_list_bracket = false
ij_c_spaces_within_empty_template_call_ltgt = false
ij_c_spaces_within_empty_template_declaration_ltgt = false
ij_c_spaces_within_for_parentheses = false
ij_c_spaces_within_function_call_parentheses = false
ij_c_spaces_within_function_declaration_parentheses = false
ij_c_spaces_within_if_parentheses = false
ij_c_spaces_within_lambda_capture_list_bracket = false
ij_c_spaces_within_method_parameter_type_parentheses = false
ij_c_spaces_within_method_return_type_parentheses = false
ij_c_spaces_within_parentheses = false
ij_c_spaces_within_property_attributes_parentheses = false
ij_c_spaces_within_protocols_brackets = false
ij_c_spaces_within_send_message_brackets = false
ij_c_spaces_within_structured_binding_list_bracket = false
ij_c_spaces_within_switch_parentheses = false
ij_c_spaces_within_template_call_ltgt = false
ij_c_spaces_within_template_declaration_ltgt = false
ij_c_spaces_within_template_double_gt = true
ij_c_spaces_within_while_parentheses = false
ij_c_special_else_if_treatment = true
ij_c_structured_binding_list_align_multiline = false
ij_c_structured_binding_list_align_multiline_bracket = false
ij_c_structured_binding_list_comma_on_next_line = false
ij_c_structured_binding_list_new_line_after_lbracket = false
ij_c_structured_binding_list_new_line_before_rbracket = false
ij_c_structured_binding_list_wrap = off
ij_c_superclass_list_after_colon = never
ij_c_superclass_list_align_multiline = true
ij_c_superclass_list_before_colon = if_long
ij_c_superclass_list_comma_on_next_line = false
ij_c_superclass_list_wrap = on_every_item
ij_c_tag_prefix_of_block_comment = at
ij_c_tag_prefix_of_line_comment = back_slash
ij_c_template_call_arguments_align_multiline = false
ij_c_template_call_arguments_align_multiline_pars = false
ij_c_template_call_arguments_comma_on_next_line = false
ij_c_template_call_arguments_new_line_after_lt = false
ij_c_template_call_arguments_new_line_before_gt = false
ij_c_template_call_arguments_wrap = off
ij_c_template_declaration_function_body_indent = false
ij_c_template_declaration_function_wrap = split_into_lines
ij_c_template_declaration_struct_body_indent = false
ij_c_template_declaration_struct_wrap = split_into_lines
ij_c_template_parameters_align_multiline = false
ij_c_template_parameters_align_multiline_pars = false
ij_c_template_parameters_comma_on_next_line = false
ij_c_template_parameters_new_line_after_lt = false
ij_c_template_parameters_new_line_before_gt = false
ij_c_template_parameters_wrap = off
ij_c_ternary_operation_signs_on_next_line = true
ij_c_ternary_operation_wrap = normal
ij_c_type_qualifiers_placement = before
ij_c_use_modern_casts = true
ij_c_use_setters_in_constructor = true
ij_c_while_brace_force = never
ij_c_while_on_new_line = false
ij_c_wrap_property_declaration = off
[{*.cmake,CMakeLists.txt}]
ij_cmake_align_multiline_parameters_in_calls = false
ij_cmake_force_commands_case = 2
ij_cmake_keep_blank_lines_in_code = 2
ij_cmake_space_before_for_parentheses = true
ij_cmake_space_before_if_parentheses = true
ij_cmake_space_before_method_call_parentheses = false
ij_cmake_space_before_method_parentheses = false
ij_cmake_space_before_while_parentheses = true
ij_cmake_spaces_within_for_parentheses = false
ij_cmake_spaces_within_if_parentheses = false
ij_cmake_spaces_within_method_call_parentheses = false
ij_cmake_spaces_within_method_parentheses = false
ij_cmake_spaces_within_while_parentheses = false
[{*.gant,*.groovy,*.gy}]
ij_groovy_align_group_field_declarations = false
ij_groovy_align_multiline_array_initializer_expression = false
ij_groovy_align_multiline_assignment = false
ij_groovy_align_multiline_binary_operation = false
ij_groovy_align_multiline_chained_methods = false
ij_groovy_align_multiline_extends_list = false
ij_groovy_align_multiline_for = true
ij_groovy_align_multiline_list_or_map = true
ij_groovy_align_multiline_method_parentheses = false
ij_groovy_align_multiline_parameters = true
ij_groovy_align_multiline_parameters_in_calls = false
ij_groovy_align_multiline_resources = true
ij_groovy_align_multiline_ternary_operation = false
ij_groovy_align_multiline_throws_list = false
ij_groovy_align_named_args_in_map = true
ij_groovy_align_throws_keyword = false
ij_groovy_array_initializer_new_line_after_left_brace = false
ij_groovy_array_initializer_right_brace_on_new_line = false
ij_groovy_array_initializer_wrap = off
ij_groovy_assert_statement_wrap = off
ij_groovy_assignment_wrap = off
ij_groovy_binary_operation_wrap = off
ij_groovy_blank_lines_after_class_header = 0
ij_groovy_blank_lines_after_imports = 1
ij_groovy_blank_lines_after_package = 1
ij_groovy_blank_lines_around_class = 1
ij_groovy_blank_lines_around_field = 0
ij_groovy_blank_lines_around_field_in_interface = 0
ij_groovy_blank_lines_around_method = 1
ij_groovy_blank_lines_around_method_in_interface = 1
ij_groovy_blank_lines_before_imports = 1
ij_groovy_blank_lines_before_method_body = 0
ij_groovy_blank_lines_before_package = 0
ij_groovy_block_brace_style = end_of_line
ij_groovy_block_comment_add_space = false
ij_groovy_block_comment_at_first_column = true
ij_groovy_call_parameters_new_line_after_left_paren = false
ij_groovy_call_parameters_right_paren_on_new_line = false
ij_groovy_call_parameters_wrap = off
ij_groovy_catch_on_new_line = false
ij_groovy_class_annotation_wrap = split_into_lines
ij_groovy_class_brace_style = end_of_line
ij_groovy_class_count_to_use_import_on_demand = 5
ij_groovy_do_while_brace_force = never
ij_groovy_else_on_new_line = false
ij_groovy_enable_groovydoc_formatting = true
ij_groovy_enum_constants_wrap = off
ij_groovy_extends_keyword_wrap = off
ij_groovy_extends_list_wrap = off
ij_groovy_field_annotation_wrap = split_into_lines
ij_groovy_finally_on_new_line = false
ij_groovy_for_brace_force = never
ij_groovy_for_statement_new_line_after_left_paren = false
ij_groovy_for_statement_right_paren_on_new_line = false
ij_groovy_for_statement_wrap = off
ij_groovy_ginq_general_clause_wrap_policy = 2
ij_groovy_ginq_having_wrap_policy = 1
ij_groovy_ginq_indent_having_clause = true
ij_groovy_ginq_indent_on_clause = true
ij_groovy_ginq_on_wrap_policy = 1
ij_groovy_ginq_space_after_keyword = true
ij_groovy_if_brace_force = never
ij_groovy_import_annotation_wrap = 2
ij_groovy_imports_layout = *,|,javax.**,java.**,|,$*
ij_groovy_indent_case_from_switch = true
ij_groovy_indent_label_blocks = true
ij_groovy_insert_inner_class_imports = false
ij_groovy_keep_blank_lines_before_right_brace = 2
ij_groovy_keep_blank_lines_in_code = 2
ij_groovy_keep_blank_lines_in_declarations = 2
ij_groovy_keep_control_statement_in_one_line = true
ij_groovy_keep_first_column_comment = true
ij_groovy_keep_indents_on_empty_lines = false
ij_groovy_keep_line_breaks = true
ij_groovy_keep_multiple_expressions_in_one_line = false
ij_groovy_keep_simple_blocks_in_one_line = false
ij_groovy_keep_simple_classes_in_one_line = true
ij_groovy_keep_simple_lambdas_in_one_line = true
ij_groovy_keep_simple_methods_in_one_line = true
ij_groovy_label_indent_absolute = false
ij_groovy_label_indent_size = 0
ij_groovy_lambda_brace_style = end_of_line
ij_groovy_layout_static_imports_separately = true
ij_groovy_line_comment_add_space = false
ij_groovy_line_comment_add_space_on_reformat = false
ij_groovy_line_comment_at_first_column = true
ij_groovy_method_annotation_wrap = split_into_lines
ij_groovy_method_brace_style = end_of_line
ij_groovy_method_call_chain_wrap = off
ij_groovy_method_parameters_new_line_after_left_paren = false
ij_groovy_method_parameters_right_paren_on_new_line = false
ij_groovy_method_parameters_wrap = off
ij_groovy_modifier_list_wrap = false
ij_groovy_names_count_to_use_import_on_demand = 3
ij_groovy_packages_to_use_import_on_demand = java.awt.*,javax.swing.*
ij_groovy_parameter_annotation_wrap = off
ij_groovy_parentheses_expression_new_line_after_left_paren = false
ij_groovy_parentheses_expression_right_paren_on_new_line = false
ij_groovy_prefer_parameters_wrap = false
ij_groovy_resource_list_new_line_after_left_paren = false
ij_groovy_resource_list_right_paren_on_new_line = false
ij_groovy_resource_list_wrap = off
ij_groovy_space_after_assert_separator = true
ij_groovy_space_after_colon = true
ij_groovy_space_after_comma = true
ij_groovy_space_after_comma_in_type_arguments = true
ij_groovy_space_after_for_semicolon = true
ij_groovy_space_after_quest = true
ij_groovy_space_after_type_cast = true
ij_groovy_space_before_annotation_parameter_list = false
ij_groovy_space_before_array_initializer_left_brace = false
ij_groovy_space_before_assert_separator = false
ij_groovy_space_before_catch_keyword = true
ij_groovy_space_before_catch_left_brace = true
ij_groovy_space_before_catch_parentheses = true
ij_groovy_space_before_class_left_brace = true
ij_groovy_space_before_closure_left_brace = true
ij_groovy_space_before_colon = true
ij_groovy_space_before_comma = false
ij_groovy_space_before_do_left_brace = true
ij_groovy_space_before_else_keyword = true
ij_groovy_space_before_else_left_brace = true
ij_groovy_space_before_finally_keyword = true
ij_groovy_space_before_finally_left_brace = true
ij_groovy_space_before_for_left_brace = true
ij_groovy_space_before_for_parentheses = true
ij_groovy_space_before_for_semicolon = false
ij_groovy_space_before_if_left_brace = true
ij_groovy_space_before_if_parentheses = true
ij_groovy_space_before_method_call_parentheses = false
ij_groovy_space_before_method_left_brace = true
ij_groovy_space_before_method_parentheses = false
ij_groovy_space_before_quest = true
ij_groovy_space_before_record_parentheses = false
ij_groovy_space_before_switch_left_brace = true
ij_groovy_space_before_switch_parentheses = true
ij_groovy_space_before_synchronized_left_brace = true
ij_groovy_space_before_synchronized_parentheses = true
ij_groovy_space_before_try_left_brace = true
ij_groovy_space_before_try_parentheses = true
ij_groovy_space_before_while_keyword = true
ij_groovy_space_before_while_left_brace = true
ij_groovy_space_before_while_parentheses = true
ij_groovy_space_in_named_argument = true
ij_groovy_space_in_named_argument_before_colon = false
ij_groovy_space_within_empty_array_initializer_braces = false
ij_groovy_space_within_empty_method_call_parentheses = false
ij_groovy_spaces_around_additive_operators = true
ij_groovy_spaces_around_assignment_operators = true
ij_groovy_spaces_around_bitwise_operators = true
ij_groovy_spaces_around_equality_operators = true
ij_groovy_spaces_around_lambda_arrow = true
ij_groovy_spaces_around_logical_operators = true
ij_groovy_spaces_around_multiplicative_operators = true
ij_groovy_spaces_around_regex_operators = true
ij_groovy_spaces_around_relational_operators = true
ij_groovy_spaces_around_shift_operators = true
ij_groovy_spaces_within_annotation_parentheses = false
ij_groovy_spaces_within_array_initializer_braces = false
ij_groovy_spaces_within_braces = true
ij_groovy_spaces_within_brackets = false
ij_groovy_spaces_within_cast_parentheses = false
ij_groovy_spaces_within_catch_parentheses = false
ij_groovy_spaces_within_for_parentheses = false
ij_groovy_spaces_within_gstring_injection_braces = false
ij_groovy_spaces_within_if_parentheses = false
ij_groovy_spaces_within_list_or_map = false
ij_groovy_spaces_within_method_call_parentheses = false
ij_groovy_spaces_within_method_parentheses = false
ij_groovy_spaces_within_parentheses = false
ij_groovy_spaces_within_switch_parentheses = false
ij_groovy_spaces_within_synchronized_parentheses = false
ij_groovy_spaces_within_try_parentheses = false
ij_groovy_spaces_within_tuple_expression = false
ij_groovy_spaces_within_while_parentheses = false
ij_groovy_special_else_if_treatment = true
ij_groovy_ternary_operation_wrap = off
ij_groovy_throws_keyword_wrap = off
ij_groovy_throws_list_wrap = off
ij_groovy_use_flying_geese_braces = false
ij_groovy_use_fq_class_names = false
ij_groovy_use_fq_class_names_in_javadoc = true
ij_groovy_use_relative_indents = false
ij_groovy_use_single_class_imports = true
ij_groovy_variable_annotation_wrap = off
ij_groovy_while_brace_force = never
ij_groovy_while_on_new_line = false
ij_groovy_wrap_chain_calls_after_dot = false
ij_groovy_wrap_long_lines = false
[{*.kt,*.kts}]
indent_style = space
insert_final_newline = true
max_line_length = 100
indent_size = 2
ij_continuation_indent_size = 2
ij_java_names_count_to_use_import_on_demand = 9999
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true
ij_kotlin_align_multiline_parameters_in_calls = false
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_assignment_wrap = normal
ij_kotlin_blank_lines_after_class_header = 0
ij_kotlin_blank_lines_around_block_when_branches = 0
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
ij_kotlin_block_comment_at_first_column = true
ij_kotlin_call_parameters_new_line_after_left_paren = true
ij_kotlin_call_parameters_right_paren_on_new_line = false
ij_kotlin_call_parameters_wrap = on_every_item
ij_kotlin_catch_on_new_line = false
ij_kotlin_class_annotation_wrap = split_into_lines
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
ij_kotlin_continuation_indent_for_chained_calls = true
ij_kotlin_continuation_indent_for_expression_bodies = true
ij_kotlin_continuation_indent_in_argument_lists = true
ij_kotlin_continuation_indent_in_elvis = false
ij_kotlin_continuation_indent_in_if_conditions = false
ij_kotlin_continuation_indent_in_parameter_lists = false
ij_kotlin_continuation_indent_in_supertype_lists = false
ij_kotlin_else_on_new_line = false
ij_kotlin_enum_constants_wrap = off
ij_kotlin_extends_list_wrap = normal
ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = false
ij_kotlin_import_nested_classes = false
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
ij_kotlin_keep_blank_lines_in_declarations = 2
ij_kotlin_keep_first_column_comment = true
ij_kotlin_keep_indents_on_empty_lines = false
ij_kotlin_keep_line_breaks = true
ij_kotlin_lbrace_on_next_line = false
ij_kotlin_line_comment_add_space = false
ij_kotlin_line_comment_at_first_column = true
ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = normal
ij_kotlin_method_parameters_new_line_after_left_paren = true
ij_kotlin_method_parameters_right_paren_on_new_line = true
ij_kotlin_method_parameters_wrap = on_every_item
ij_kotlin_name_count_to_use_star_import = 9999
ij_kotlin_name_count_to_use_star_import_for_members = 9999
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true
ij_kotlin_space_after_type_colon = true
ij_kotlin_space_before_catch_parentheses = true
ij_kotlin_space_before_comma = false
ij_kotlin_space_before_extend_colon = true
ij_kotlin_space_before_for_parentheses = true
ij_kotlin_space_before_if_parentheses = true
ij_kotlin_space_before_lambda_arrow = true
ij_kotlin_space_before_type_colon = false
ij_kotlin_space_before_when_parentheses = true
ij_kotlin_space_before_while_parentheses = true
ij_kotlin_spaces_around_additive_operators = true
ij_kotlin_spaces_around_assignment_operators = true
ij_kotlin_spaces_around_equality_operators = true
ij_kotlin_spaces_around_function_type_arrow = true
ij_kotlin_spaces_around_logical_operators = true
ij_kotlin_spaces_around_multiplicative_operators = true
ij_kotlin_spaces_around_range = false
ij_kotlin_spaces_around_relational_operators = true
ij_kotlin_spaces_around_unary_operator = false
ij_kotlin_spaces_around_when_arrow = true
ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false
[{*.har,*.json}]
indent_size = 2
ij_json_array_wrapping = split_into_lines
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = split_into_lines
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = false
ij_json_spaces_within_brackets = false
ij_json_wrap_long_lines = false
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
ij_html_align_attributes = true
ij_html_align_text = false
ij_html_attribute_wrap = normal
ij_html_block_comment_add_space = false
ij_html_block_comment_at_first_column = true
ij_html_do_not_align_children_of_min_lines = 0
ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
ij_html_enforce_quotes = false
ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var
ij_html_keep_blank_lines = 2
ij_html_keep_indents_on_empty_lines = false
ij_html_keep_line_breaks = true
ij_html_keep_line_breaks_in_text = true
ij_html_keep_whitespaces = false
ij_html_keep_whitespaces_inside = span,pre,textarea
ij_html_line_comment_at_first_column = true
ij_html_new_line_after_last_attribute = never
ij_html_new_line_before_first_attribute = never
ij_html_quote_style = double
ij_html_remove_new_line_before_tags = br
ij_html_space_after_tag_name = false
ij_html_space_around_equality_in_attribute = false
ij_html_space_inside_empty_tag = false
ij_html_text_wrap = normal
[{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock}]
ij_toml_keep_indents_on_empty_lines = false
================================================
FILE: .gitattributes
================================================
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
# Binary files should be left untouched
*.jar binary
================================================
FILE: .github/actions/prepare-emulator-action/action.yml
================================================
name: 'Prepare Emulator'
description: 'Common emulator setup steps'
runs:
using: "composite"
steps:
# This is needed for hardware acceleration, see https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/
- name: Enable Hardware Acceleration
shell: bash
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# This is needed to accept the Android license, see https://issuetracker.google.com/issues/193118030
- name: Accept Android SDK License
uses: android-actions/setup-android@v3
================================================
FILE: .github/actions/setup-action/action.yml
================================================
name: 'Setup'
description: 'Common setup steps'
inputs:
gradle-encryption-key:
description: "The key used to encrypt the Gradle cache"
required: true
runs:
using: "composite"
steps:
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
# Only save Gradle User Home state for builds on the 'main' branch.
# Builds on other branches will only read existing entries from the cache.
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Don't reuse cache entries from any other Job.
gradle-home-cache-strict-match: true
cache-encryption-key: ${{ inputs.gradle-encryption-key }}
================================================
FILE: .github/workflows/blueprints-starter-ci.yml
================================================
name: Build Starter Blueprint (Android + iOS + WASM + Desktop)
on:
push:
paths:
- 'blueprints/starter/**'
- '.github/**'
tags-ignore:
- '**'
pull_request:
paths:
- 'blueprints/starter/**'
- '.github/**'
permissions:
contents: read
jobs:
build-ios-starter-app:
runs-on: macos-latest-xlarge
timeout-minutes: 25
defaults:
run:
working-directory: blueprints/starter
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build iOS Framework
run: ./gradlew :app:linkDebugFrameworkIosSimulatorArm64
build-wasm-starter-app:
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: blueprints/starter
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build WASM binary
run: ./gradlew :app:wasmJsBrowserDistribution
build-android-starter-app:
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: blueprints/starter
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Android
run: ./gradlew :app:assembleDebug
build-desktop-starter-app:
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: blueprints/starter
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Desktop binary
run: ./gradlew :app:desktopMainClasses
ktfmt:
runs-on: macos-latest
timeout-minutes: 25
defaults:
run:
working-directory: blueprints/starter
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Install ktfmt
run: brew install ktfmt
- name: Run ktfmt
run: ktfmt --google-style --dry-run --set-exit-if-changed $(find . -type f -name "*.kt")
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- main
tags-ignore:
- '**'
paths-ignore:
- '**/*.md'
pull_request:
permissions:
contents: read
jobs:
test-android:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Test
run: ./gradlew testDebugUnitTest --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-ios:
runs-on: macos-latest-xlarge
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Test
run: ./gradlew iosSimulatorArm64Test -Pkotlin.incremental.native=false --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-desktop:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Test
run: ./gradlew desktopTest --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-linux:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Test
run: ./gradlew linuxX64Test --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-wasm:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Test
run: ./gradlew wasmJsTest --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-jvm-modules:
name: test-jvm-modules
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Test
run: ./gradlew :kotlin-inject-extensions:contribute:impl-code-generators:test :metro-extensions:contribute:impl-code-generators:test :metro-extensions:contribute:impl-compiler-plugin:test --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-emulator-renderer-android-view:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Prepare emulator
uses: ./.github/actions/prepare-emulator-action
- name: Test
run: ./gradlew :renderer-android-view:public:emulatorCheck --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-emulator-renderer-compose-multiplatform:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Prepare emulator
uses: ./.github/actions/prepare-emulator-action
- name: Test
run: ./gradlew :renderer-compose-multiplatform:public:emulatorCheck --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-emulator-sample-app:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Prepare emulator
uses: ./.github/actions/prepare-emulator-action
- name: Test
run: ./gradlew :sample:app:emulatorCheck --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
test-emulator-sample-app-ksp:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Prepare emulator
uses: ./.github/actions/prepare-emulator-action
- name: Test
run: ./gradlew :sample:app:emulatorCheck -Papp.platform.metro.ksp=true --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
build-ios-sample-app:
runs-on: macos-latest-xlarge
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# The command to build is executed by the Android Studio iOS app run action.
#
# The destination id was printed in the Github Action console
#
# { platform:iOS Simulator, id:77D15A8A-0E47-4200-A192-A0C6311C808D, OS:18.2, name:iPhone SE (3rd generation) }
#
#
# Downloading the iOS platform is needed with the latest macos runners. This is quite slow, so we should see
# if can we avoid this in the future.
# See: https://github.com/actions/runner-images/issues/12758#issuecomment-3206748945
- name: Build swift
run: |
/usr/bin/xcodebuild -version
/usr/bin/xcodebuild -showsdks
/usr/bin/xcrun simctl list devices
export DESTINATION_DEVICE=`/usr/bin/xcrun simctl list devices | grep -A 1 "iOS 18.6" | grep -oE '\([0-9A-F-]+\)' | head -1 | tr -d '()'`
echo "Using simulator $DESTINATION_DEVICE"
/usr/bin/xcodebuild -project sample/iosApp/iosApp.xcodeproj -scheme iosApp -configuration Debug OBJROOT=build/ios SYMROOT=build/ios -destination id=$DESTINATION_DEVICE -allowProvisioningDeviceRegistration -allowProvisioningUpdates
build-wasm-sample-app:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build wasm binary
run: ./gradlew :sample:app:wasmJsBrowserDistribution
binary-compatibility-check:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: API check
run: ./gradlew apiCheck --stacktrace --show-version --continue
ktfmt:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: ktfmt
run: ./gradlew ktfmtCheck --stacktrace --show-version --continue
android-lint:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Android Lint
run: ./gradlew lint --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
detekt:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Detekt
run: ./gradlew detekt --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
module-structure-check:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Module Structure Check
run: ./gradlew checkModuleStructureDependencies --stacktrace --show-version --continue
publish-maven-local:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Publish to Maven Local
run: |
./gradlew publishToMavenLocal --stacktrace --show-version --no-configuration-cache
./gradlew -p gradle-plugin publishToMavenLocal --stacktrace --show-version --no-configuration-cache
build-src:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run release task
run: ./gradlew -p buildSrc release --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
gradle-plugin:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run release task
run: ./gradlew -p gradle-plugin release --stacktrace --show-version --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results-${{ github.job }}
path: ./**/build/reports/
================================================
FILE: .github/workflows/pages.yml
================================================
# Simple workflow for deploying static content to GitHub Pages
name: Deploy Wiki
on:
# Runs on pushes targeting the default branch
push:
branches:
- main
# Allows to run this workflow manually from the Actions tab.
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: write
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build-wasm-sample-app:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build wasm binary
run: ./gradlew :sample:app:wasmJsBrowserDistribution :recipes:app:wasmJsBrowserDistribution
- name: Upload wasm binaries
uses: actions/upload-artifact@v4
with:
name: wasm-files
path: |
./sample/app/build/dist/wasmJs/productionExecutable/
./recipes/app/build/dist/wasmJs/productionExecutable/
build-mkdocs:
needs: build-wasm-sample-app
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Download wasm binaries
uses: actions/download-artifact@v4
with:
name: wasm-files
path: docs/web
- run: |
cp CHANGELOG.md docs/changelog.md
- run: |
pip install mkdocs-material
pip install "mkdocs-material[imaging]"
- run: mkdocs gh-deploy --config-file mkdocs.yml --force
deploy-mkdocs:
needs: build-mkdocs
if: github.repository == 'amzn/app-platform'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: gh-pages
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
================================================
FILE: .github/workflows/publish-release.yml
================================================
name: Publish Release
on:
push:
tags:
- '*.*.*'
permissions:
contents: read
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "release-upload"
cancel-in-progress: false
jobs:
publish-release:
# Needed for creating the release: https://github.com/softprops/action-gh-release?tab=readme-ov-file#permissions
permissions:
contents: write
runs-on: macos-latest-xlarge
if: github.repository == 'amzn/app-platform'
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Publish release
run: |
./gradlew clean publishAndReleaseToMavenCentral -PRELEASE_SIGNING_ENABLED=true --no-build-cache --stacktrace --show-version --no-configuration-cache
./gradlew -p gradle-plugin clean publishAndReleaseToMavenCentral -PRELEASE_SIGNING_ENABLED=true --no-build-cache --stacktrace --show-version --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_PORTAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PORTAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Extract release notes
id: release_notes
uses: ffurrer2/extract-release-notes@v2
- name: Check if pre-release
id: prerelease
run: |
version=$(grep VERSION_NAME gradle.properties | cut -d'=' -f2)
if [[ $version == *"-beta"* ]]; then
echo "isPrerelease=true" >> $GITHUB_OUTPUT
else
echo "isPrerelease=false" >> $GITHUB_OUTPUT
fi
- name: Create release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.release_notes.outputs.release_notes }}
prerelease: ${{ steps.prerelease.outputs.isPrerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/publish-snapshot.yml
================================================
name: Publish Snapshot
on:
push:
branches:
- main
paths-ignore:
- '**/*.md'
permissions:
contents: read
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "snapshot-upload"
cancel-in-progress: false
jobs:
publish-snapshot:
runs-on: macos-latest-xlarge
if: github.repository == 'amzn/app-platform'
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup-action
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Publish snapshot
run: |
./gradlew clean publish -PRELEASE_SIGNING_ENABLED=true --no-build-cache --stacktrace --show-version --no-configuration-cache
./gradlew -p gradle-plugin clean publish -PRELEASE_SIGNING_ENABLED=true --no-build-cache --stacktrace --show-version --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_PORTAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PORTAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY_PASSWORD }}
================================================
FILE: .gitignore
================================================
# Gradle
.gradle
/.gradle/
build/
!/scripts/**/build
local.properties
/reports/
# IntelliJ IDEA
.idea/*
!.idea/ktfmt.xml
*.iml
*.ipl
*.ipr
*.iws
.shelf/
# kotlin
.kotlin
# iOS
**/xcuserdata/
# Steve Jobs
.DS_Store
================================================
FILE: .idea/ktfmt.xml
================================================
================================================
FILE: AGENTS.md
================================================
# AGENTS.md
## Purpose
This repository is the Amazon App Platform: a Kotlin Multiplatform application framework plus example applications and a starter blueprint. The core concepts are documented in [`docs/`](docs/) and implemented across reusable library modules plus a few app entrypoints.
Start here before changing code:
- `README.md`
- `docs/index.md`
- `docs/setup.md`
- `docs/module-structure.md`
- `docs/di.md`
- `docs/presenter.md`
- `docs/renderer.md`
- `docs/template.md`
- `docs/testing.md`
- `settings.gradle`
- `buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/`
`mkdocs.yml` is the docs site manifest. The Pages workflow builds Wasm artifacts for `:sample:app` and `:recipes:app` and copies them into `docs/web/` before publishing.
## Repo Shape
Important top-level areas:
- `gradle-plugin/`: the published `software.amazon.app.platform` Gradle plugin.
- `buildSrc/`: repo-local convention plugins used by this repository’s own modules. This is where platform targets, emulator config, desktop packaging, and Wasm defaults are defined.
- `docs/`: framework documentation. Treat this as the authoritative product docs.
- `sample/`: the main sample app. This is the best place to study end-to-end usage of scopes, DI, presenters, renderers, templates, fakes, and robots.
- `recipes/`: a second example app plus reusable “recipe” patterns, including the separate `recipesIosApp` SwiftUI/Xcode wrapper.
- `blueprints/starter/`: a standalone starter app template with its own Gradle wrapper, version catalog, and README.
Core framework module families:
- `scope`, `di-common`
- `presenter`, `presenter-molecule`
- `renderer`, `renderer-android-view`, `renderer-compose-multiplatform`
- `robot`, `robot-compose-multiplatform`, `robot-internal`
- `kotlin-inject`, `kotlin-inject-extensions`
- `metro`, `metro-extensions`
- `ksp-common`
Compiler plugin work currently lives in:
- `metro-extensions/contribute/impl-compiler-plugin/`: JVM-only Kotlin compiler plugin module for Metro-backed App Platform DI extensions such as `@ContributesRobot`. `src/main/` contains FIR generation and diagnostics. `src/test/resources/box`, `diagnostics`, and `dump` contain compiler test data. `src/test/java/.../runners/` contains generated JUnit test runners and must be regenerated with `generateTests` after adding or renaming test data files.
## Architecture Rules
The most important repo rule is the module structure documented in `docs/module-structure.md`.
- `:public` modules expose reusable APIs and shared code.
- `:impl` modules contain concrete implementations.
- `:testing` modules hold shared fakes and test helpers.
- `:*-robots` modules hold shared UI robots.
- `:app` modules are the only modules allowed to depend on `:impl` modules.
Do not introduce a dependency from a non-`:app` module to an `:impl` module. The build enforces this via `checkModuleStructureDependencies`.
The framework’s architectural flow is:
1. `Scope` and DI assemble objects for a lifecycle boundary.
2. `MoleculePresenter` implementations produce models.
3. App-specific `Template` presenters wrap the root model tree.
4. `RendererFactory` resolves platform renderers for those models.
5. Thin platform entrypoints bootstrap the root scope and start rendering.
Representative entrypoints:
- Android: `sample/app/src/androidMain/.../AndroidApplication.kt`, `MainActivity.kt`
- iOS: `sample/app/src/iosMain/.../MainViewController.kt`, `sample/iosApp/`
- Desktop: `sample/app/src/desktopMain/.../Main.kt`, `DesktopApp.kt`
- Wasm: `sample/app/src/wasmJsMain/.../Main.kt`
## Toolchain
Local development should match CI as closely as possible. These versions live in `gradle/libs.versions.toml`.
Expected warning: Gradle prints a warning that configuration-on-demand is not supported for Wasm targets. This is noisy but currently normal in this repo.
For Metro compiler-plugin work, prefer source over decompiled artifacts:
- Reference implementation: `https://github.com/square/metro-extensions`
- Metro source: use a local checkout if you have one, otherwise upstream Metro on GitHub
- Avoid relying on `.gradle/caches` or decompiled JARs when the source is available
## Run The Apps
There are three app-style entrypoints to care about:
- `:sample:app`: main sample app inside the root build.
- `:recipes:app`: recipe/demo app inside the root build.
- `blueprints/starter`: standalone starter app; run commands from inside that directory or use its own `./gradlew`.
### Android
Install the debug APK onto a connected device or emulator:
```bash
./gradlew :sample:app:installDebug
./gradlew :recipes:app:installDebug
```
For the standalone starter:
```bash
cd blueprints/starter
./gradlew :app:installDebug
```
`buildSrc/.../BaseAndroidPlugin.kt` configures managed emulator tests with a local device named `emulator` using a Pixel 3 / API 30 `aosp-atd` image.
### iOS
Sample app:
```bash
open sample/iosApp/iosApp.xcodeproj
```
Recipe app:
```bash
open recipes/recipesIosApp/recipesIosApp.xcodeproj
```
The Xcode projects include a shell build phase that calls Gradle:
- `:sample:app:embedAndSignAppleFrameworkForXcode`
- `:recipes:app:embedAndSignAppleFrameworkForXcode`
If you only want to build the Kotlin framework without opening Xcode:
```bash
./gradlew :sample:app:linkDebugFrameworkIosSimulatorArm64
./gradlew :recipes:app:linkDebugFrameworkIosSimulatorArm64
```
CI builds the sample iOS wrapper with `xcodebuild -project sample/iosApp/iosApp.xcodeproj -scheme iosApp ... -destination id=`. Use `xcrun simctl list devices` to pick a simulator if you need a pure CLI invocation.
### Desktop
Run the desktop Compose app:
```bash
./gradlew :sample:app:run
./gradlew :recipes:app:run
```
Starter blueprint:
```bash
cd blueprints/starter
./gradlew :app:run
```
Desktop packaging tasks such as `packageDmg`, `packageDeb`, and `packageMsi` are available on app modules.
### Wasm
Development server:
```bash
./gradlew :sample:app:wasmJsBrowserDevelopmentRun
./gradlew :recipes:app:wasmJsBrowserDevelopmentRun
```
Production bundle:
```bash
./gradlew :sample:app:wasmJsBrowserDistribution
./gradlew :recipes:app:wasmJsBrowserDistribution
```
Starter blueprint:
```bash
cd blueprints/starter
./gradlew :app:wasmJsBrowserDevelopmentRun
```
After a production Wasm build, serve the generated files from:
- `sample/app/build/dist/wasmJs/productionExecutable/`
- `recipes/app/build/dist/wasmJs/productionExecutable/`
The starter README suggests `npx http-server` from the production output directory.
## Run The Tests
### Repo-wide CI-style checks
These are the main root-level quality gates used by GitHub Actions:
```bash
./gradlew testDebugUnitTest
./gradlew iosSimulatorArm64Test -Pkotlin.incremental.native=true
./gradlew desktopTest
./gradlew linuxX64Test
./gradlew wasmJsTest
./gradlew apiCheck
./gradlew ktfmtCheck
./gradlew detekt
./gradlew lint
./gradlew checkModuleStructureDependencies
```
### Sample app tests by platform
Android instrumented UI tests:
```bash
./gradlew :sample:app:emulatorCheck
```
Or against a manually started device:
```bash
./gradlew :sample:app:connectedDebugAndroidTest
```
Desktop UI tests:
```bash
./gradlew :sample:app:desktopTest
```
Android unit tests:
```bash
./gradlew :sample:app:testDebugUnitTest
```
iOS simulator tests:
```bash
./gradlew :sample:app:iosSimulatorArm64Test -Pkotlin.incremental.native=true
```
All sample app target tests:
```bash
./gradlew :sample:app:allTests
```
### Metro compiler-plugin module
Run these from the repo root:
```bash
./gradlew :metro-extensions:contribute:impl-compiler-plugin:test
./gradlew :metro-extensions:contribute:impl-compiler-plugin:test --tests 'software.amazon.app.platform.metro.compiler.runners.BoxTestGenerated$Metro.testTinyGraph'
./gradlew :metro-extensions:contribute:impl-compiler-plugin:test -PupdateTestData
./gradlew :metro-extensions:contribute:impl-compiler-plugin:generateTests
./gradlew :metro-extensions:contribute:impl-compiler-plugin:ktfmtCheck
```
Use this workflow for compiler tests:
- Add new test data under `src/test/resources/box`, `diagnostics`, or `dump`
- Run `:metro-extensions:contribute:impl-compiler-plugin:generateTests` after adding or renaming test data files
- Run `:metro-extensions:contribute:impl-compiler-plugin:test`
- Use `-PupdateTestData` when intentionally updating FIR or IR golden files
Test data conventions for this module:
- `box/`: compile-and-run tests. Each file exposes `fun box(): String` and should return `"OK"`.
- `diagnostics/`: compiler error tests with inline diagnostic markers plus `.fir.diag.txt` golden files.
- `dump/`: compiler dump tests with `.fir.txt` goldens, plus `.fir.kt.txt` files for IR text dumps.
`apiCheck` and `apiDump` are disabled for this module, so do not use them as validation commands here.
### Where tests live
- Android UI tests: `sample/app/src/androidInstrumentedTest/`
- Desktop UI tests: `sample/app/src/desktopTest/`
- Shared unit tests: `sample/*/src/commonTest/`
- Shared fakes: `sample/user/testing/`
- Shared robots: `sample/login/impl-robots/`, `sample/user/impl-robots/`
- Compiler plugin test data: `metro-extensions/contribute/impl-compiler-plugin/src/test/resources/`
- Generated compiler test runners: `metro-extensions/contribute/impl-compiler-plugin/src/test/java/software/amazon/app/platform/metro/compiler/runners/`
## Current Test Reality
As of this checkout:
- `:sample:app:desktopTest` runs successfully.
- `:sample:app:testDebugUnitTest` succeeds but currently has `NO-SOURCE`.
- `:sample:app:iosSimulatorArm64Test -Pkotlin.incremental.native=true` succeeds but is currently skipped because `sample/app` has no iOS test sources.
- Android UI coverage for the sample app is in `androidInstrumentedTest` and is exercised through `emulatorCheck`/`connectedDebugAndroidTest`.
## Wasm Lockfile Caveat
Wasm tasks are currently strict about the committed Yarn lockfile under `kotlin-js-store/wasm/yarn.lock`.
If a Wasm task fails with:
```text
Execution failed for task ':kotlinWasmStoreYarnLock'.
Lock file was changed. Run the `kotlinWasmUpgradeYarnLock` task to actualize lock file
```
then the generated `build/wasm/yarn.lock` does not match the committed lock. In this checkout, both `:sample:app:wasmJsTest` and `:sample:app:wasmJsBrowserDistribution` hit that failure.
Treat `kotlinWasmUpgradeYarnLock` as an intentional dependency update step, not a routine run command. If you change Wasm/npm dependencies on purpose, update and review `kotlin-js-store/wasm/yarn.lock` in the same change.
## Docs Workflow
To work on docs locally:
```bash
cp CHANGELOG.md docs/changelog.md
pip install mkdocs-material "mkdocs-material[imaging]"
mkdocs serve
```
When changing framework behavior, update both:
- the relevant `docs/*.md` page
- the sample and/or starter code that demonstrates that behavior
If a change affects how consumers start a new project, also update `blueprints/starter/README.md`.
================================================
FILE: CHANGELOG.md
================================================
# Change Log
## [Unreleased]
### Added
### Changed
### Deprecated
### Removed
- Removed Apple x86_64 targets from the repository builds by dropping `iosX64` where it was still configured, aligning with Compose Multiplatform's removal of Apple x86_64 target support: https://kotlinlang.org/docs/multiplatform/whats-new-compose-111.html#dropped-support-for-apple-x86-64-targets
### Fixed
### Security
### Other Notes & Contributions
## [0.0.10] - 2026-04-20
### Added
- Migrate the blueprints/starter app from kotlin-inject to Metro, see #178
- Add a compiler plugin for Metro extensions, see #179. The compiler plugin is now used by default, but the KSP implementations can be enabled by setting the Gradle property `-Papp.platform.metro.ksp=true`.
## Changed
- Metro to `1.0.0-RC2`
## [0.0.9] - 2026-04-13
### Added
- Convert the sample app to [Metro](https://zacsweers.github.io/metro/), see #173. With the recent Kotlin and Metro version updates, issues we saw with Metro and targets other than Android/JVM are solved, and Metro is now the [recommended default](https://amzn.github.io/app-platform/di/) for dependency injection.
### Changed
- Kotlin to `2.3.20`
- Gradle to `9.4.1`
- metro to `0.13.2`
## [0.0.8] - 2026-01-27
### Added
- Added a recipe for `Presenter` integration with SwiftUI, see #154.
### Changed
- Kotlin to `2.2.21`, see #161
- KSP to `2.3.4`
- kotlin-inject to `0.9.0`
- kotlin-inject-anvil to `0.1.7`
- metro to `0.10.1`
- Remove testing for KSP1 and use KSP2
### Other Notes & Contributions
- Special thanks to [@rvenable](https://github.com/rvenable) for creating the original Swift APIs that served as the foundation for #154!
## [0.0.7] - 2025-09-26
### Changed
- Changed the min SDK from 21 to 23, see #149.
### Fixed
- Fix NPE when removing Android Views from multiple child renderers with the same parent on activity destruction, see #150.
## [0.0.6] - 2025-09-05
### Added
- Added support for [Metro](https://zacsweers.github.io/metro/) as dependency injection framework. User can choose between [`kotlin-inject-anvil`](https://github.com/amzn/kotlin-inject-anvil) and [Metro](https://zacsweers.github.io/metro/). For more details see the [documentation](https://amzn.github.io/app-platform/di/) for how to setup and use both dependency injection frameworks with App Platform.
### Changed
- Changed the provided `CoroutineScope` within `ViewRenderer` from a custom scope to `MainScope()`, see #124.
- Disallow changing the parent View for `ViewRenderers`. For a different parent view `RendererFactory.getRenderer()` will now return a new `Renderer` instead of the cached instance. The cached instance is only returned for the same parent view, see #139.
### Deprecated
- Deprecated `diComponent()` and introduce `kotlinInjectComponent()` as replacement, see #106.
- Deprecated `RendererFactory.getChildRendererForParent()`. `RendererFactory.getRenderer()` now provides the same functionality, see #139.
### Fixed
- Fix and stop suppressing NPE when removing Android Views, which lead to an inconsistent state and potential crashes laters, see #136.
- Cancel the `CoroutineScope` in `ViewRenderer` in rare cases where `onDetach` for the view isn't triggered. This caused potential leaks, see #140.
## [0.0.5] - 2025-08-15
### Added
- Added support for the new [Android-KMP library plugin](https://developer.android.com/kotlin/multiplatform/plugin) in App Platform's Gradle plugin.
- Added a [recipe](https://amzn.github.io/app-platform/presenter/#navigation-3) for how to use the Navigation 3 library with App Platform.
### Changed
- Upgraded Kotlin to `2.2.10`.
## [0.0.4] - 2025-07-25
### Added
- Added a search field to the wiki.
- Added a [blueprint project](https://github.com/amzn/app-platform/tree/main/blueprints/starter) for App Platform that can be copied to spin up new projects faster, see #63.
- Added support for back press events in `Presenters`. The API is similar to the one from Compose Multiplatform and Android Compose. See the [documentation in the wiki](https://amzn.github.io/app-platform/presenter/#back-gestures) for more details.
- Added a [recipes application](https://amzn.github.io/app-platform/#web-recipe-app) showing solutions to common problems. All solutions have been [documented in the wiki](https://amzn.github.io/app-platform/presenter/#recipes).
### Changed
- Upgraded Kotlin to `2.2.0`.
## [0.0.3] - 2025-05-28
### Added
- Wasm JS is now officially supported and artifacts are published.
### Changed
- Snapshots are now published to the Central Portal Snapshots repository at https://central.sonatype.com/repository/maven-snapshots/.
- Upgraded Kotlin to `2.1.21`.
### Removed
- Removed the deprecated `onEvent` function used in `MoleculePresenters`. This is no longer needed since Kotlin 2.0.20, see #21.
## [0.0.2] - 2025-05-02
### Changed
- **Breaking change:** Changed the constructor from `ComposeAndroidRendererFactory` to two factory functions instead. A new API allows you to use this factory without an Android View as parent, see #39.
### Deprecated
- Deprecated the `onEvent` function used in `MoleculePresenters`. This is no longer needed since Kotlin 2.0.20, see #21.
### Fixed
- Made the `ModuleStructureDependencyCheckTask` cacheable, see #19.
- Fixed violations for Gradle's project isolation feature, see #20.
### Other Notes
- Updated the sample application with a shared transition animation to highlight how animations can be implemented for `Template` updates, see #37.
## [0.0.1] - 2025-04-17
- Initial release.
[Unreleased]: https://github.com/amzn/app-platform/compare/0.0.10...HEAD
[0.0.10]: https://github.com/amzn/app-platform/compare/0.0.10
[0.0.9]: https://github.com/amzn/app-platform/compare/0.0.9
[0.0.8]: https://github.com/amzn/app-platform/compare/0.0.8
[0.0.7]: https://github.com/amzn/app-platform/compare/0.0.7
[0.0.6]: https://github.com/amzn/app-platform/compare/0.0.6
[0.0.5]: https://github.com/amzn/app-platform/compare/0.0.5
[0.0.4]: https://github.com/amzn/app-platform/compare/0.0.4
[0.0.3]: https://github.com/amzn/app-platform/compare/0.0.3
[0.0.2]: https://github.com/amzn/app-platform/compare/0.0.2
[0.0.1]: https://github.com/amzn/app-platform/compare/0.0.1
================================================
FILE: CODE_OF_CONDUCT.md
================================================
## Code of Conduct
This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
opensource-codeofconduct@amazon.com with any additional questions or comments.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing Guidelines
Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
documentation, we greatly value feedback and contributions from our community.
Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
information to effectively respond to your bug report or contribution.
## Reporting Bugs/Feature Requests
We welcome you to use the GitHub issue tracker to report bugs or suggest features.
When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
* A reproducible test case or series of steps
* The version of our code being used
* Any modifications you've made relevant to the bug
* Anything unusual about your environment or deployment
## Contributing via Pull Requests
Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
1. You are working against the latest source on the *main* branch.
2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
To send us a pull request, please:
1. Fork the repository.
2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
3. Ensure local tests pass.
4. Commit to your fork using clear commit messages.
5. Send us a pull request, answering any default questions in the pull request interface.
6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
[creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
## Finding contributions to work on
Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
## Code of Conduct
This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
opensource-codeofconduct@amazon.com with any additional questions or comments.
## Security issue notifications
If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
## Licensing
See the [LICENSE](https://github.com/amzn/app-platform/blob/main/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
================================================
FILE: NOTICE
================================================
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
================================================
FILE: README.md
================================================
# App Platform
[](https://central.sonatype.com/search?smo=true&namespace=software.amazon.app.platform)
[](https://github.com/amzn/app-platform/actions/workflows/ci.yml)
The App Platform is a lightweight application framework for state and memory management suitable
for Kotlin Multiplatform projects, in particular Android, iOS, JVM, native and Web. It makes the
dependency inversion and dependency injection (DI) design patterns first class principles to develop
features and support the variety of platforms. The UI layer is entirely decoupled from the business logic,
which allows different application targets to change the look and feel.
### [amzn.github.io/app-platform](https://amzn.github.io/app-platform/)
## Security
See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
## License
This project is licensed under the Apache-2.0 License.
================================================
FILE: RELEASING.md
================================================
# Production Releases
1. Checkout `origin/main`.
2. Update the `CHANGELOG.md` file with the changes of this release (the format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
* Copy the template for the next unreleased version at the top.
* Delete unused section in the new release.
* Update the links at the bottom of the CHANGELOG.md file and don't forget to change the link for the unreleased version.
3. Update the version in `gradle.properties` and remove the `-SNAPSHOT` suffix.
4. Commit the changes and create a tag:
```
git commit -am "Releasing 0.1.0."
git tag 0.1.0
```
5. Update the version in `gradle.properties` and add the `-SNAPSHOT` suffix.
6. Commit the change:
```
git commit -am "Prepare next development version."
```
7. Push the two commits. This will start a Github action that publishes the release to Maven Central and creates a new release on Github.
```
git push && git push --tags
```
# Snapshot Releases
Snapshot releases are automatically created whenever a commit to the `main` branch is pushed.
# Manually uploading a release
Depending on the version in the `gradle.properties` file it will be either a production or snapshot release.
```
./gradlew clean publish --no-build-cache
```
# Installing in Maven Local
```
./gradlew publishToMavenLocal
```
================================================
FILE: blueprints/README.md
================================================
# Blueprints
This folder contains reusable templates ("blueprints") to help you quickly get started with projects using [App Platform](https://github.com/amzn/app-platform).
## 📁 `starter/`
The `starter/` blueprint provides everything you need to bootstrap a new project with App Platform. It includes:
- Pre-configured `build.gradle.kts` files for Kotlin Multiplatform
- Android + iOS + Desktop + WASM targets with Compose UI enabled
- App Platform integrations like Molecule presenters and Kotlin Inject
- A working module structure with navigation and templates
> 💡 More blueprints may be added in the future to support different project styles or configurations.
================================================
FILE: blueprints/starter/.gitignore
================================================
# Gradle
.gradle
/.gradle/
build/
local.properties
/reports/
# IntelliJ IDEA
.idea/*
*.iml
*.ipl
*.ipr
*.iws
.shelf/
# kotlin
.kotlin
# iOS
**/xcuserdata/
# Steve Jobs
.DS_Store
================================================
FILE: blueprints/starter/README.md
================================================
# Template App for Amazon App Platform
This is a Kotlin Multiplatform template application built using the [Amazon App Platform](https://github.com/amzn/app-platform). It provides a modern, opinionated starting point for building scalable, testable, and multiplatform Compose applications.
## Overview
This template demonstrates:
- Kotlin Multiplatform targeting Android, iOS, WebAssembly (WASM), and Desktop (JVM)
- [App Platform](https://github.com/amzn/app-platform) conventions for Metro DI, state, rendering, and navigation
- Molecule-powered presenters
- Scoped dependency injection using Metro graphs, `@ContributesBinding`, `@SingleIn`, `@ContributesScoped`, and `@ContributesRenderer`
- Reactive state with `StateFlow`
- Compose UI for Android, Desktop, and WASM
- Modular code structure for feature separation
## Features
- `ExampleRepository`: A simple `StateFlow`-based repository that emits data
- `ExampleValueGenerator`: A scoped class that updates the repository with random values every 3 seconds
- `NavigationHeaderPresenter` and `NavigationDetailPresenter`: Molecule presenters driving the top bar and content UI
- `NavigationHeaderRenderer` and `NavigationDetailRenderer`: A ComposeRenderer showing example state
## Modules
- `:app` – Main app entrypoint using Compose + App Platform + Metro
- `:templates` – Main module for templates and the entry point into the application
- `:navigation` – Example feature module
## Running the App
### Android
```bash
./gradlew :app:installDebug
```
### WASM (WebAssembly)
```bash
./gradlew :app:wasmJsBrowserDevelopmentRun
```
### iOS
#### Option 1: Run from IntelliJ IDEA or Android Studio
1. Install the [Kotlin Multiplatform IDE plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform).
2. Select the iosApp run configuration and run the app.
#### Option 2: Run via Xcode
1. Open the Xcode project:
```bash
open iosApp/iosApp.xcodeproj
```
2. Select a simulator and run the app (`Cmd + R`)
> The required Kotlin Multiplatform framework will be built automatically as part of the Xcode build process (`./gradlew :app:embedAndSignAppleFrameworkForXcode`).
### Desktop (JVM)
```bash
./gradlew :app:run
```
> This runs the desktop Compose app using the JVM target.
## Formatting
### ktfmt
```bash
ktfmt **/*.kt --google-style
```
> This will run through all the kt files and format them.
## Configuration
You can modify app behavior by editing:
- `gradle.properties` – JVM and native memory settings
- `libs.versions.toml` – Centralized dependency version catalog
- `app/build.gradle.kts` – Platform-specific targets and UI modules
## Contributing
Feel free to fork and adapt this template for your own projects. If you find bugs or improvements related to App Platform usage, consider opening issues or PRs against [amzn/app-platform](https://github.com/amzn/app-platform).
## License
This project inherits the license of the [Amazon App Platform](https://github.com/amzn/app-platform).
================================================
FILE: blueprints/starter/app/build.gradle.kts
================================================
@file:OptIn(ExperimentalWasmDsl::class)
import dev.zacsweers.metro.gradle.DiagnosticSeverity
import dev.zacsweers.metro.gradle.MetroPluginExtension
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import software.amazon.app.platform.gradle.AppPlatformPlugin
plugins {
alias(libs.plugins.appPlatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
appPlatform {
enableComposeUi(true)
enableMetro(true)
enableModuleStructure(true)
enableMoleculePresenters(true)
addImplModuleDependencies(true)
}
configure {
unusedGraphInputsSeverity.set(DiagnosticSeverity.NONE)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
targets.withType().configureEach {
binaries.framework {
baseName = "TemplateApp"
AppPlatformPlugin.exportedDependencies().forEach { export(it) }
}
}
wasmJs {
outputModuleName = project.path.removePrefix(":").replace(":", "-")
binaries.executable()
browser {
commonWebpackConfig {
outputFileName = "template-app.js"
}
}
}
sourceSets {
val desktopMain by getting
commonMain {
dependencies {
implementation(project(":navigation:impl"))
implementation(project(":templates:impl"))
AppPlatformPlugin.exportedDependencies().forEach { api(it) }
}
}
androidMain {
dependencies {
implementation(libs.androidx.activity.compose)
}
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.coroutines.swing)
}
}
}
android {
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "software.amazon.app.platform.template"
versionCode = 1
versionName = "1.0"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
}
compose.desktop {
application {
mainClass = "software.amazon.app.platform.template.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "TemplateApp"
packageVersion = "1.0.0"
}
}
}
================================================
FILE: blueprints/starter/app/src/androidMain/AndroidManifest.xml
================================================
================================================
FILE: blueprints/starter/app/src/androidMain/kotlin/software/amazon/app/platform/template/AndroidAppGraph.kt
================================================
package software.amazon.app.platform.template
import android.app.Application
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides
import software.amazon.app.platform.scope.RootScopeProvider
/**
* The final Android app graph. Note that [application] is an Android specific type and classes
* living in the Android source folder can therefore inject [Application].
*/
@DependencyGraph(AppScope::class)
interface AndroidAppGraph {
/** The factory to create a new instance of [AndroidAppGraph]. */
@DependencyGraph.Factory
fun interface Factory {
/**
* Creates a new [AndroidAppGraph] instance. [application] and [rootScopeProvider] are provided
* in the [AndroidAppGraph] and can be injected.
*/
fun create(
@Provides application: Application,
@Provides rootScopeProvider: RootScopeProvider,
): AndroidAppGraph
}
}
================================================
FILE: blueprints/starter/app/src/androidMain/kotlin/software/amazon/app/platform/template/AndroidApplication.kt
================================================
package software.amazon.app.platform.template
import android.app.Application
import dev.zacsweers.metro.createGraphFactory
import software.amazon.app.platform.scope.RootScopeProvider
import software.amazon.app.platform.scope.Scope
/**
* The [Application] class of our sample app. Note that this class implements [RootScopeProvider].
* This is helpful to get access to the root scope from Android components such as activities.
*/
open class AndroidApplication : Application(), RootScopeProvider {
private val templateApplication = software.amazon.app.platform.template.Application()
override val rootScope: Scope
get() = templateApplication.rootScope
override fun onCreate() {
templateApplication.create(metroGraph(templateApplication))
super.onCreate()
}
/** Create the [AppGraph]. In UI tests we use a different instance. */
protected open fun metroGraph(
templateApplication: software.amazon.app.platform.template.Application
): AppGraph {
return createGraphFactory().create(this, templateApplication)
}
}
================================================
FILE: blueprints/starter/app/src/androidMain/kotlin/software/amazon/app/platform/template/MainActivity.kt
================================================
package software.amazon.app.platform.template
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import software.amazon.app.platform.renderer.ComposeAndroidRendererFactory
import software.amazon.app.platform.renderer.getComposeRenderer
import software.amazon.app.platform.scope.RootScopeProvider
/**
* The only `Activity` of our sample app. This class is just an entry point to start rendering
* templates.
*/
class MainActivity : ComponentActivity() {
private val rootScopeProvider
get() = application as RootScopeProvider
private val viewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val rendererFactory =
ComposeAndroidRendererFactory.createForComposeUi(rootScopeProvider = rootScopeProvider)
setContent {
val template by viewModel.templates.collectAsState()
val renderer = rendererFactory.getComposeRenderer(template)
renderer.renderCompose(template)
}
}
}
================================================
FILE: blueprints/starter/app/src/androidMain/kotlin/software/amazon/app/platform/template/MainActivityViewModel.kt
================================================
package software.amazon.app.platform.template
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import kotlinx.coroutines.flow.StateFlow
import software.amazon.app.platform.scope.RootScopeProvider
import software.amazon.app.platform.scope.di.metro.metroDependencyGraph
import software.amazon.app.platform.template.templates.AppTemplate
/**
* `ViewModel` that hosts the stream of templates and survives configuration changes. Note that we
* use [application] to get access to the root scope.
*/
class MainActivityViewModel(application: Application) : AndroidViewModel(application) {
private val graph = (application as RootScopeProvider).rootScope.metroDependencyGraph()
private val templateProvider = graph.templateProviderFactory.createTemplateProvider()
/** The stream of templates that are rendered by [MainActivity]. */
val templates: StateFlow = templateProvider.templates
override fun onCleared() {
templateProvider.cancel()
}
/** Graph interface to give us access to objects from the app graph. */
@ContributesTo(AppScope::class)
interface Graph {
/** Gives access to the [TemplateProvider.Factory] from the object graph. */
val templateProviderFactory: TemplateProvider.Factory
}
}
================================================
FILE: blueprints/starter/app/src/androidMain/res/values/strings.xml
================================================
TemplateApp
================================================
FILE: blueprints/starter/app/src/commonMain/kotlin/software/amazon/app/platform/template/AppGraph.kt
================================================
package software.amazon.app.platform.template
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.ForScope
import dev.zacsweers.metro.Multibinds
import software.amazon.app.platform.scope.Scoped
import software.amazon.app.platform.scope.coroutine.CoroutineScopeScoped
/**
* Shared interface for the app graph. The final graphs live in the platform specific source folders
* in order to have access to platform specific code.
*/
@ContributesTo(AppScope::class)
interface AppGraph {
/** All [Scoped] instances part of the app scope. */
@Multibinds(allowEmpty = true) @ForScope(AppScope::class) val appScopedInstances: Set
/** The coroutine scope that runs as long as the app scope is alive. */
@ForScope(AppScope::class) val appScopeCoroutineScopeScoped: CoroutineScopeScoped
}
================================================
FILE: blueprints/starter/app/src/commonMain/kotlin/software/amazon/app/platform/template/Application.kt
================================================
package software.amazon.app.platform.template
import software.amazon.app.platform.scope.RootScopeProvider
import software.amazon.app.platform.scope.Scope
import software.amazon.app.platform.scope.coroutine.addCoroutineScopeScoped
import software.amazon.app.platform.scope.di.metro.addMetroDependencyGraph
import software.amazon.app.platform.scope.register
/**
* Shared class between the platform to manage the root scope. It itself implements the
* [RootScopeProvider] interface.
*/
class Application : RootScopeProvider {
private var _rootScope: Scope? = null
override val rootScope: Scope
get() = checkNotNull(_rootScope) { "Must call create() first." }
/** Creates the root scope and remembers the instance. */
fun create(appGraph: AppGraph) {
check(_rootScope == null) { "create() should be called only once." }
_rootScope = Scope.buildRootScope {
addMetroDependencyGraph(appGraph)
addCoroutineScopeScoped(appGraph.appScopeCoroutineScopeScoped)
}
// Register instances after the rootScope has been set to avoid race conditions for Scoped
// instances that may use the rootScope.
rootScope.register(appGraph.appScopedInstances)
}
/** Destroys the root scope. */
fun destroy() {
rootScope.destroy()
_rootScope = null
}
}
================================================
FILE: blueprints/starter/app/src/commonMain/kotlin/software/amazon/app/platform/template/TemplateProvider.kt
================================================
package software.amazon.app.platform.template
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.flow.StateFlow
import software.amazon.app.platform.presenter.molecule.MoleculeScope
import software.amazon.app.platform.presenter.molecule.MoleculeScopeFactory
import software.amazon.app.platform.presenter.molecule.launchMoleculePresenter
import software.amazon.app.platform.template.navigation.NavigationPresenter
import software.amazon.app.platform.template.templates.AppTemplate
import software.amazon.app.platform.template.templates.AppTemplatePresenter
/**
* Shared class between all platforms to start collecting [AppTemplate] in a [StateFlow]. Inject
* [Factory] to create a new instance. Once the instance is no longer needed, call [cancel] to clean
* up any resources.
*
* [NavigationPresenter] serves as the root presenter and gets wrapped in a [AppTemplatePresenter].
*/
@AssistedInject
class TemplateProvider(
presenter: NavigationPresenter,
templatePresenterFactory: AppTemplatePresenter.Factory,
@Assisted private val moleculeScope: MoleculeScope,
) {
/** The templates that should be rendered in the UI. */
val templates: StateFlow by lazy {
moleculeScope
.launchMoleculePresenter(
presenter = templatePresenterFactory.createAppTemplatePresenter(presenter),
input = Unit,
)
.model
}
/** Releases all resources and stops [templates] from updating further. */
fun cancel() {
moleculeScope.cancel()
}
/**
* The assisted factory for Metro to create a new [TemplateProvider]. This factory is wrapped by
* [Factory], which should be used instead.
*/
@AssistedFactory
fun interface InternalFactory {
/** Create a new instance of [TemplateProvider] with the given [MoleculeScope]. */
fun create(moleculeScope: MoleculeScope): TemplateProvider
}
/** Factory class to create a new instance of [TemplateProvider]. */
@Inject
class Factory(
private val moleculeScopeFactory: MoleculeScopeFactory,
private val templateProviderFactory: InternalFactory,
) {
/**
* Creates a new instance of [TemplateProvider]. Call [TemplateProvider.cancel] when the
* instance not needed anymore to avoid leaking resources.
*/
fun createTemplateProvider(): TemplateProvider {
return templateProviderFactory.create(moleculeScopeFactory.createMoleculeScope())
}
}
}
================================================
FILE: blueprints/starter/app/src/desktopMain/kotlin/software/amazon/app/platform/template/DesktopApp.kt
================================================
package software.amazon.app.platform.template
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import software.amazon.app.platform.renderer.ComposeRendererFactory
import software.amazon.app.platform.renderer.getComposeRenderer
import software.amazon.app.platform.scope.RootScopeProvider
import software.amazon.app.platform.scope.Scope
import software.amazon.app.platform.scope.di.metro.metroDependencyGraph
/**
* Responsible for creating the app graph [graph] and producing templates. Call [destroy] to clean
* up any resources.
*/
class DesktopApp(private val graph: (RootScopeProvider) -> AppGraph) : RootScopeProvider {
override val rootScope: Scope
get() = application.rootScope
private val application = Application().apply { create(graph(this)) }
private val templateProvider =
rootScope.metroDependencyGraph().templateProviderFactory.createTemplateProvider()
/** Call this composable function to start rendering templates on the screen. */
@Composable
fun renderTemplates() {
val template by templateProvider.templates.collectAsState()
val factory = remember { ComposeRendererFactory(application) }
val renderer = factory.getComposeRenderer(template)
renderer.renderCompose(template)
}
/** Cancels and releases all resources. */
fun destroy() {
templateProvider.cancel()
application.destroy()
}
/** Graph interface to give us access to objects from the app graph. */
@ContributesTo(AppScope::class)
interface Graph {
/** Gives access to the [TemplateProvider.Factory] from the object graph. */
val templateProviderFactory: TemplateProvider.Factory
}
}
================================================
FILE: blueprints/starter/app/src/desktopMain/kotlin/software/amazon/app/platform/template/DesktopAppGraph.kt
================================================
package software.amazon.app.platform.template
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides
import software.amazon.app.platform.scope.RootScopeProvider
/**
* The final Desktop app graph. Unlike the Android and iOS specific counterpart, this class doesn't
* have any platform specific types.
*/
@DependencyGraph(AppScope::class)
interface DesktopAppGraph {
/** The factory to create a new instance of [DesktopAppGraph]. */
@DependencyGraph.Factory
fun interface Factory {
/**
* Creates a new [DesktopAppGraph] instance. [rootScopeProvider] is provided in the
* [DesktopAppGraph] and can be injected.
*/
fun create(@Provides rootScopeProvider: RootScopeProvider): DesktopAppGraph
}
}
================================================
FILE: blueprints/starter/app/src/desktopMain/kotlin/software/amazon/app/platform/template/Main.kt
================================================
package software.amazon.app.platform.template
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import dev.zacsweers.metro.createGraphFactory
/** The main function to launch the Desktop app. */
fun main() {
val desktopApp = DesktopApp { createGraphFactory().create(it) }
application {
Window(
onCloseRequest = {
desktopApp.destroy()
exitApplication()
},
alwaysOnTop = true,
title = "Template App",
) {
desktopApp.renderTemplates()
}
}
}
================================================
FILE: blueprints/starter/app/src/iosMain/kotlin/software/amazon/app/platform/template/IosAppGraph.kt
================================================
package software.amazon.app.platform.template
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.createGraphFactory
import platform.UIKit.UIApplication
import software.amazon.app.platform.scope.RootScopeProvider
/**
* The final iOS app graph. Note that [uiApplication] is an iOS specific type and classes living in
* the iOS source folder can therefore inject [UIApplication].
*/
@DependencyGraph(AppScope::class)
interface IosAppGraph {
/** The factory to create a new instance of [IosAppGraph]. */
@DependencyGraph.Factory
fun interface Factory {
/**
* Creates a new [IosAppGraph] instance. [uiApplication] and [rootScopeProvider] are provided in
* the [IosAppGraph] and can be injected.
*/
fun create(
@Provides uiApplication: UIApplication,
@Provides rootScopeProvider: RootScopeProvider,
): IosAppGraph
}
/** Gives access to the [TemplateProvider.Factory] from the object graph. */
val templateProviderFactory: TemplateProvider.Factory
}
/** This function is called from Swift to create a new graph instance. */
@Suppress("unused")
fun createIosAppGraph(application: UIApplication, rootScopeProvider: RootScopeProvider): AppGraph {
return createGraphFactory().create(application, rootScopeProvider)
}
================================================
FILE: blueprints/starter/app/src/iosMain/kotlin/software/amazon/app/platform/template/MainViewController.kt
================================================
package software.amazon.app.platform.template
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController
import software.amazon.app.platform.renderer.ComposeRendererFactory
import software.amazon.app.platform.renderer.Renderer
import software.amazon.app.platform.scope.RootScopeProvider
import software.amazon.app.platform.scope.di.metro.metroDependencyGraph
/**
* This function is called from Swift to hook up the Compose Multiplatform UI.
*
* This is our entry point to start producing templates and hooking up our [Renderer] runtime. Other
* platforms extract this code into classes that are effectively singletons. But this approach is
* good enough for the iOS sample.
*/
@Suppress("unused")
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
ComposeUIViewController {
// Create a single instance.
val templateProvider = remember {
rootScopeProvider.rootScope
.metroDependencyGraph()
.templateProviderFactory
.createTemplateProvider()
}
DisposableEffect(Unit) {
onDispose {
// Cancel the provider when it's no longer needed.
templateProvider.cancel()
}
}
// Only a single factory is needed.
val factory = remember { ComposeRendererFactory(rootScopeProvider) }
// Render templates using our Renderer runtime.
val template by templateProvider.templates.collectAsState()
val renderer = factory.getRenderer(template::class)
renderer.renderCompose(template)
}
================================================
FILE: blueprints/starter/app/src/wasmJsMain/kotlin/software/amazon/app/platform/template/Main.kt
================================================
package software.amazon.app.platform.template
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.zacsweers.metro.createGraphFactory
import kotlinx.browser.document
import software.amazon.app.platform.renderer.ComposeRendererFactory
import software.amazon.app.platform.scope.di.metro.metroDependencyGraph
/** The entry point of our sample app. */
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(checkNotNull(document.body)) { AppPlatform() }
}
@Composable
private fun AppPlatform() {
val application = remember {
Application().apply { create(createGraphFactory().create(this)) }
}
// Create a single instance.
val templateProvider = remember {
application.rootScope
.metroDependencyGraph()
.templateProviderFactory
.createTemplateProvider()
}
DisposableEffect(Unit) {
onDispose {
// Cancel the provider when it's no longer needed.
templateProvider.cancel()
}
}
// Only a single factory is needed.
val factory = remember { ComposeRendererFactory(application) }
// Render templates using our Renderer runtime.
val template by templateProvider.templates.collectAsState()
val renderer = factory.getRenderer(template::class)
renderer.renderCompose(template)
}
================================================
FILE: blueprints/starter/app/src/wasmJsMain/kotlin/software/amazon/app/platform/template/WasmJsAppGraph.kt
================================================
package software.amazon.app.platform.template
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides
import software.amazon.app.platform.scope.RootScopeProvider
/**
* The final Wasm app graph.
*
* Unlike the Android and iOS specific counterpart, this class doesn't have any platform specific
* types.
*/
@DependencyGraph(AppScope::class)
interface WasmJsAppGraph {
/** The factory to create a new instance of [WasmJsAppGraph]. */
@DependencyGraph.Factory
fun interface Factory {
/**
* Creates a new [WasmJsAppGraph] instance. [rootScopeProvider] is provided in the
* [WasmJsAppGraph] and can be injected.
*/
fun create(@Provides rootScopeProvider: RootScopeProvider): WasmJsAppGraph
}
/** Gives access to the [TemplateProvider.Factory] from the object graph. */
val templateProviderFactory: TemplateProvider.Factory
}
================================================
FILE: blueprints/starter/app/src/wasmJsMain/resources/index.html
================================================
TemplateApp
================================================
FILE: blueprints/starter/app/src/wasmJsMain/resources/styles.css
================================================
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
================================================
FILE: blueprints/starter/build.gradle.kts
================================================
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidKmpLibrary) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.metro) apply false
alias(libs.plugins.appPlatform)
}
================================================
FILE: blueprints/starter/gradle/libs.versions.toml
================================================
[versions]
assertk = "0.28.1"
app-platform = "0.0.9"
agp = "8.13.2"
android-compileSdk = "36"
android-minSdk = "23"
android-targetSdk = "36"
androidx-activity = "1.13.0"
compose-material-icons = "1.7.3"
compose-material3 = "1.9.0"
compose-multiplatform = "1.10.3"
coroutines = "1.10.2"
kotlin = "2.3.20"
metro = "1.0.0-RC2"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
compose-material = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" }
compose-material-icons = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-material-icons" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
appPlatform = { id = "software.amazon.app.platform", version.ref = "app-platform" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
================================================
FILE: blueprints/starter/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: blueprints/starter/gradle.properties
================================================
GROUP=software.amazon.app.platform.template
org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.enableJetifier=false
android.nonTransitiveRClass=true
# https://youtrack.jetbrains.com/issue/KT-82395
kotlin.incremental.js=false
kotlin.incremental.js.klib=false
================================================
FILE: blueprints/starter/gradlew
================================================
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
================================================
FILE: blueprints/starter/gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: blueprints/starter/iosApp/Configuration/Config.xcconfig
================================================
TEAM_ID=
BUNDLE_ID=software.amazon.app.platform.template.Template
APP_NAME=Template
================================================
FILE: blueprints/starter/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
================================================
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: blueprints/starter/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
================================================
{
"images" : [
{
"filename" : "app-icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: blueprints/starter/iosApp/iosApp/Assets.xcassets/Contents.json
================================================
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: blueprints/starter/iosApp/iosApp/ComposeContentView.swift
================================================
import UIKit
import SwiftUI
import TemplateApp
struct ComposeView: UIViewControllerRepresentable {
private var rootScopeProvider: RootScopeProvider
init(rootScopeProvider: RootScopeProvider) {
self.rootScopeProvider = rootScopeProvider
}
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ComposeContentView: View {
var rootScopeProvider: RootScopeProvider
init(rootScopeProvider: RootScopeProvider) {
self.rootScopeProvider = rootScopeProvider
}
var body: some View {
ComposeView(rootScopeProvider: rootScopeProvider).ignoresSafeArea(.keyboard) // Compose has own keyboard handler
}
}
================================================
FILE: blueprints/starter/iosApp/iosApp/Info.plist
================================================
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
CFBundleName
$(PRODUCT_NAME)
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
1.0
CFBundleVersion
1
LSRequiresIPhoneOS
CADisableMinimumFrameDurationOnPhone
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
UILaunchScreen
UIRequiredDeviceCapabilities
armv7
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad
UIInterfaceOrientationPortrait
UIInterfaceOrientationPortraitUpsideDown
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
================================================
FILE: blueprints/starter/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
================================================
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: blueprints/starter/iosApp/iosApp/iOSApp.swift
================================================
import TemplateApp
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate, RootScopeProvider {
private let templateApplication: Application = Application()
var rootScope: Scope {
get {
templateApplication.rootScope
}
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
templateApplication.create(appGraph: IosAppGraphKt.createIosAppGraph(application: application, rootScopeProvider: templateApplication))
return true
}
}
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ComposeContentView(rootScopeProvider: appDelegate)
}
}
}
================================================
FILE: blueprints/starter/iosApp/iosApp.xcodeproj/project.pbxproj
================================================
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
1BACA3B135BB44908CC94158 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BACA8873D2AF36B9E0FC788 /* iOSApp.swift */; };
1BACAC1D12A9468E4FB4B657 /* ComposeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BACA6BD71FC32091FE23841 /* ComposeContentView.swift */; };
530CD4FE2E208D79001A7515 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 530CD4FA2E208D79001A7515 /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
1BACA6BD71FC32091FE23841 /* ComposeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposeContentView.swift; sourceTree = ""; };
1BACA8873D2AF36B9E0FC788 /* iOSApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
530CD4FA2E208D79001A7515 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
530CD4FB2E208D79001A7515 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
7555FF7B242A565900829871 /* Template.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Template.app; sourceTree = BUILT_PRODUCTS_DIR; };
AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
530CD4F92E208D79001A7515 /* Preview Content */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Preview Content"; sourceTree = ""; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXGroup section */
7555FF72242A565900829871 = {
isa = PBXGroup;
children = (
AB1DB47929225F7C00F7AF9C /* Configuration */,
7555FF7D242A565900829871 /* iosApp */,
7555FF7C242A565900829871 /* Products */,
);
sourceTree = "";
};
7555FF7C242A565900829871 /* Products */ = {
isa = PBXGroup;
children = (
7555FF7B242A565900829871 /* Template.app */,
);
name = Products;
sourceTree = "";
};
7555FF7D242A565900829871 /* iosApp */ = {
isa = PBXGroup;
children = (
1BACA6BD71FC32091FE23841 /* ComposeContentView.swift */,
1BACA8873D2AF36B9E0FC788 /* iOSApp.swift */,
530CD4F92E208D79001A7515 /* Preview Content */,
530CD4FA2E208D79001A7515 /* Assets.xcassets */,
530CD4FB2E208D79001A7515 /* Info.plist */,
);
path = iosApp;
sourceTree = "";
};
AB1DB47929225F7C00F7AF9C /* Configuration */ = {
isa = PBXGroup;
children = (
AB3632DC29227652001CCB65 /* Config.xcconfig */,
);
path = Configuration;
sourceTree = "";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
7555FF7A242A565900829871 /* iosApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */,
7555FF77242A565900829871 /* Sources */,
7555FF79242A565900829871 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
530CD4F92E208D79001A7515 /* Preview Content */,
);
name = iosApp;
productName = iosApp;
productReference = 7555FF7B242A565900829871 /* Template.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
7555FF73242A565900829871 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1130;
LastUpgradeCheck = 1130;
ORGANIZATIONNAME = orgName;
TargetAttributes = {
7555FF7A242A565900829871 = {
CreatedOnToolsVersion = 11.3.1;
};
};
};
buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 7555FF72242A565900829871;
productRefGroup = 7555FF7C242A565900829871 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
7555FF7A242A565900829871 /* iosApp */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
7555FF79242A565900829871 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
530CD4FE2E208D79001A7515 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Compile Kotlin Framework";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :app:embedAndSignAppleFrameworkForXcode --rerun-tasks\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
7555FF77242A565900829871 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1BACAC1D12A9468E4FB4B657 /* ComposeContentView.swift in Sources */,
1BACA3B135BB44908CC94158 /* iOSApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
7555FFA3242A565B00829871 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
7555FFA4242A565B00829871 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
7555FFA6242A565B00829871 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../app/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)";
INFOPLIST_FILE = iosApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-framework",
TemplateApp,
);
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7555FFA7242A565B00829871 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../app/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)";
INFOPLIST_FILE = iosApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-framework",
TemplateApp,
);
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7555FFA3242A565B00829871 /* Debug */,
7555FFA4242A565B00829871 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7555FFA6242A565B00829871 /* Debug */,
7555FFA7242A565B00829871 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 7555FF73242A565900829871 /* Project object */;
}
================================================
FILE: blueprints/starter/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
================================================
================================================
FILE: blueprints/starter/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
================================================
================================================
FILE: blueprints/starter/navigation/impl/build.gradle.kts
================================================
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.appPlatform)
alias(libs.plugins.androidKmpLibrary)
alias(libs.plugins.kotlinMultiplatform)
}
appPlatform {
enableComposeUi(true)
enableModuleStructure(true)
enableMetro(true)
enableMoleculePresenters(true)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
androidLibrary {
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
wasmJs {
outputModuleName = project.path.removePrefix(":").replace(":", "-")
browser()
}
sourceSets {
commonMain {
dependencies {
implementation(libs.compose.material)
implementation(libs.compose.material.icons)
implementation(project(":templates:public"))
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.assertk)
implementation(libs.coroutines.test)
implementation(project(":navigation:testing"))
}
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/ExampleRepositoryImpl.kt
================================================
package software.amazon.app.platform.template.navigation
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Default implementation of [ExampleRepository] that holds an integer [StateFlow] and allows its
* value to be updated.
*
* Useful for testing reactive state flow usage with presenters or other consumers.
*/
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class ExampleRepositoryImpl : ExampleRepository {
private val _exampleStateFlow = MutableStateFlow(0)
override val exampleStateFlow: StateFlow = _exampleStateFlow.asStateFlow()
override fun setExampleFlowValue(value: Int) {
println("value: $value")
_exampleStateFlow.value = value
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/ExampleValueGenerator.kt
================================================
package software.amazon.app.platform.template.navigation
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.delay
import software.amazon.app.platform.inject.metro.ContributesScoped
import software.amazon.app.platform.scope.Scope
import software.amazon.app.platform.scope.Scoped
import software.amazon.app.platform.scope.coroutine.launch
/**
* A scoped service that continuously generates random values and feeds them into
* [ExampleRepository] every 3 seconds. This is active only while the [AppScope] is alive.
*
* This class is:
* - Bound to [AppScope] via `@ContributesScoped`
* - A singleton within that scope via `@SingleIn`
* - Injected via constructor using `@Inject`
*
* The generator starts emitting random integers in the range 1 to 100 as soon as the scope is
* entered.
*
* @property exampleRepository the repository where generated values are pushed
*/
@Inject
@SingleIn(AppScope::class)
@ContributesScoped(AppScope::class)
class ExampleValueGenerator(private val exampleRepository: ExampleRepository) : Scoped {
override fun onEnterScope(scope: Scope) {
scope.launch {
while (true) {
val random = (1..100).random()
println("random: $random")
exampleRepository.setExampleFlowValue(random)
delay(3000L)
}
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationDetailPresenterImpl.kt
================================================
package software.amazon.app.platform.template.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.delay
import software.amazon.app.platform.template.navigation.NavigationDetailPresenter.Model
@Inject
@ContributesBinding(AppScope::class)
class NavigationDetailPresenterImpl(private val exampleRepository: ExampleRepository) :
NavigationDetailPresenter {
@Composable
override fun present(input: Unit): Model {
val exampleValue by exampleRepository.exampleStateFlow.collectAsState()
var exampleCount by remember { mutableStateOf(0) }
LaunchedEffect(exampleValue) {
// Add a delay, otherwise the state is not updating properly on iOS.
delay(1.milliseconds)
exampleCount++
}
return Model(exampleValue = exampleValue, exampleCount = exampleCount)
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationDetailRenderer.kt
================================================
package software.amazon.app.platform.template.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.renderer.ComposeRenderer
import software.amazon.app.platform.template.navigation.NavigationDetailPresenter.Model
@ContributesRenderer
class NavigationDetailRenderer : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Hello, welcome to amzn/app-platform Template App",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp),
)
Text(
text = "Every 3 seconds a new exampleValue is generated: ${model.exampleValue}",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp),
)
Text(
text = "Total number of exampleValues shown: ${model.exampleCount}",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp),
)
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationHeaderPresenterImpl.kt
================================================
package software.amazon.app.platform.template.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import software.amazon.app.platform.template.navigation.NavigationHeaderPresenter.Model
@Inject
@ContributesBinding(AppScope::class)
class NavigationHeaderPresenterImpl() : NavigationHeaderPresenter {
@Composable
override fun present(input: Unit): Model {
var clickedCount by remember { mutableStateOf(0) }
return Model(clickedCount = clickedCount) {
when (it) {
NavigationHeaderPresenter.Event.Clicked -> {
clickedCount++
}
}
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationHeaderRenderer.kt
================================================
package software.amazon.app.platform.template.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Stairs
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.renderer.ComposeRenderer
import software.amazon.app.platform.template.navigation.NavigationHeaderPresenter.Model
@ContributesRenderer
class NavigationHeaderRenderer : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Row(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Stairs,
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(8.dp))
Text(
"Template App",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleMedium,
)
}
Text(
text = "Click Me (times clicked: ${model.clickedCount})",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleMedium,
// Sends event to NavigationHeaderPresenter to be processed which will update
// the above clickedCount value.
modifier = Modifier.clickable { model.onEvent(NavigationHeaderPresenter.Event.Clicked) },
)
}
Spacer(
modifier =
Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.primary)
)
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationPresenterImpl.kt
================================================
package software.amazon.app.platform.template.navigation
import androidx.compose.runtime.Composable
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.template.templates.AppTemplate
@Inject
@ContributesBinding(AppScope::class)
class NavigationPresenterImpl(
private val navigationHeaderPresenter: NavigationHeaderPresenter,
private val navigationDetailPresenter: NavigationDetailPresenter,
) : NavigationPresenter {
@Composable
override fun present(input: Unit): BaseModel {
val navigationBarModel = navigationHeaderPresenter.present(Unit)
val navigationDetailModel = navigationDetailPresenter.present(Unit)
return AppTemplate.HeaderDetailTemplate(navigationBarModel, navigationDetailModel)
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonTest/kotlin/software/amazon/app/platform/template/navigation/NavigationDetailPresenterTest.kt
================================================
package software.amazon.app.platform.template.navigation
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import software.amazon.app.platform.presenter.molecule.test
@OptIn(ExperimentalCoroutinesApi::class)
class NavigationDetailPresenterTest {
@Test
fun `model changes when setExampleFlowValue is called`() = runTest {
val exampleRepository = FakeExampleRepository()
NavigationDetailPresenterImpl(exampleRepository).test(this) {
awaitItem().let { model ->
assertThat(model.exampleValue).isEqualTo(0)
assertThat(model.exampleCount).isEqualTo(0)
}
exampleRepository.setExampleFlowValue(5)
awaitItem().let { model -> assertThat(model.exampleValue).isEqualTo(5) }
// There is a 1 milli delay within presenter before updating count.
advanceTimeBy(1.milliseconds)
awaitItem().let { model ->
assertThat(model.exampleValue).isEqualTo(5)
assertThat(model.exampleCount).isEqualTo(1)
}
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonTest/kotlin/software/amazon/app/platform/template/navigation/NavigationHeaderPresenterTest.kt
================================================
package software.amazon.app.platform.template.navigation
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import software.amazon.app.platform.presenter.molecule.test
@OptIn(ExperimentalCoroutinesApi::class)
class NavigationHeaderPresenterTest {
@Test
fun `correctly process and emit model when Clicked event is triggered`() = runTest {
NavigationHeaderPresenterImpl().test(this) {
awaitItem().let { model ->
assertThat(model.clickedCount).isEqualTo(0)
model.onEvent(NavigationHeaderPresenter.Event.Clicked)
}
awaitItem().let { model ->
assertThat(model.clickedCount).isEqualTo(1)
model.onEvent(NavigationHeaderPresenter.Event.Clicked)
}
awaitItem().let { model -> assertThat(model.clickedCount).isEqualTo(2) }
}
}
}
================================================
FILE: blueprints/starter/navigation/impl/src/commonTest/kotlin/software/amazon/app/platform/template/navigation/NavigationPresenterImplTest.kt
================================================
package software.amazon.app.platform.template.navigation
import androidx.compose.runtime.Composable
import assertk.assertThat
import assertk.assertions.isInstanceOf
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import software.amazon.app.platform.presenter.molecule.test
import software.amazon.app.platform.template.templates.AppTemplate
class NavigationPresenterImplTest {
@Test
fun `correct template and presenter models are returned`() = runTest {
val presenter =
NavigationPresenterImpl(
navigationHeaderPresenter = FakeNavigationHeaderPresenter(),
navigationDetailPresenter = FakeNavigationDetailPresenter(),
)
presenter.test(this) {
awaitItem().let { template ->
assertThat(template).isInstanceOf()
(template as? AppTemplate.HeaderDetailTemplate)?.let { headerDetailTemplate ->
assertThat(headerDetailTemplate.header).isInstanceOf()
assertThat(headerDetailTemplate.detail).isInstanceOf()
}
}
}
}
private class FakeNavigationDetailPresenter : NavigationDetailPresenter {
@Composable
override fun present(input: Unit): NavigationDetailPresenter.Model =
NavigationDetailPresenter.Model(exampleValue = 5, exampleCount = 1)
}
private class FakeNavigationHeaderPresenter : NavigationHeaderPresenter {
@Composable
override fun present(input: Unit): NavigationHeaderPresenter.Model =
NavigationHeaderPresenter.Model(clickedCount = 0) {}
}
}
================================================
FILE: blueprints/starter/navigation/public/build.gradle.kts
================================================
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.appPlatform)
alias(libs.plugins.androidKmpLibrary)
alias(libs.plugins.kotlinMultiplatform)
}
appPlatform {
enableModuleStructure(true)
enableMoleculePresenters(true)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
androidLibrary {
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
wasmJs {
outputModuleName = project.path.removePrefix(":").replace(":", "-")
browser()
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.coroutines.core)
}
}
}
}
================================================
FILE: blueprints/starter/navigation/public/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/ExampleRepository.kt
================================================
package software.amazon.app.platform.template.navigation
import kotlinx.coroutines.flow.StateFlow
/**
* Interface of an example repository to show how to correctly contribute, inject, and use within
* presenters.
*/
interface ExampleRepository {
val exampleStateFlow: StateFlow
fun setExampleFlowValue(value: Int)
}
================================================
FILE: blueprints/starter/navigation/public/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationDetailPresenter.kt
================================================
package software.amazon.app.platform.template.navigation
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
/** Presenter responsible for the state of the main content area beneath the navigation header. */
interface NavigationDetailPresenter : MoleculePresenter {
data class Model(val exampleValue: Int, val exampleCount: Int) : BaseModel
}
================================================
FILE: blueprints/starter/navigation/public/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationHeaderPresenter.kt
================================================
package software.amazon.app.platform.template.navigation
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
/**
* Presenter responsible for the state of the top navigation bar (header).
*
* This typically controls high-level UI elements such as titles, toggle buttons, or contextual
* actions that affect the overall screen.
*/
interface NavigationHeaderPresenter : MoleculePresenter {
data class Model(val clickedCount: Int, val onEvent: (Event) -> Unit) : BaseModel
/** Events that can be triggered by the UI layer (Renderer) and processed by the Presenter. */
sealed interface Event {
data object Clicked : Event
}
}
================================================
FILE: blueprints/starter/navigation/public/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/NavigationPresenter.kt
================================================
package software.amazon.app.platform.template.navigation
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
/**
* A presenter that hosts other presenters and returns their models. For that reason this presenter
* doesn't have its own [BaseModel] type and returns [BaseModel].
*/
interface NavigationPresenter : MoleculePresenter
================================================
FILE: blueprints/starter/navigation/testing/build.gradle.kts
================================================
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.appPlatform)
alias(libs.plugins.androidKmpLibrary)
alias(libs.plugins.kotlinMultiplatform)
}
appPlatform {
enableModuleStructure(true)
enableMoleculePresenters(true)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
androidLibrary {
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
wasmJs {
outputModuleName = project.path.removePrefix(":").replace(":", "-")
browser()
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.coroutines.core)
}
}
}
}
================================================
FILE: blueprints/starter/navigation/testing/src/commonMain/kotlin/software/amazon/app/platform/template/navigation/FakeExampleRepository.kt
================================================
package software.amazon.app.platform.template.navigation
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Fake implementation of [ExampleRepository], which is useful in unit tests.
*
* This class is part of the `:testing` module and shared with other modules.
*/
class FakeExampleRepository(
override val exampleStateFlow: MutableStateFlow = MutableStateFlow(0)
) : ExampleRepository {
override fun setExampleFlowValue(value: Int) {
exampleStateFlow.value = value
}
}
================================================
FILE: blueprints/starter/settings.gradle.kts
================================================
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Template"
include(":app")
include(":navigation:impl")
include(":navigation:public")
include(":navigation:testing")
include(":templates:impl")
include(":templates:public")
================================================
FILE: blueprints/starter/templates/impl/build.gradle.kts
================================================
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.appPlatform)
alias(libs.plugins.androidKmpLibrary)
alias(libs.plugins.kotlinMultiplatform)
}
appPlatform {
enableComposeUi(true)
enableModuleStructure(true)
enableMetro(true)
enableMoleculePresenters(true)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
androidLibrary {
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
wasmJs {
outputModuleName = project.path.removePrefix(":").replace(":", "-")
browser()
}
sourceSets {
commonMain {
dependencies {
implementation(libs.compose.material)
}
}
}
}
================================================
FILE: blueprints/starter/templates/impl/src/commonMain/kotlin/software/amazon/app/platform/template/templates/ComposeAppTemplateRenderer.kt
================================================
package software.amazon.app.platform.template.templates
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.Inject
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.renderer.ComposeRenderer
import software.amazon.app.platform.renderer.RendererFactory
import software.amazon.app.platform.renderer.getComposeRenderer
/**
* A Compose renderer implementation for templates used in the sample application.
*
* [rendererFactory] is used to get the [software.amazon.app.platform.renderer.Renderer] for the
* [software.amazon.app.platform.presenter.BaseModel] wrapped in the template.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Inject
@ContributesRenderer
class ComposeAppTemplateRenderer(private val rendererFactory: RendererFactory) :
ComposeRenderer() {
@Composable
override fun Compose(model: AppTemplate) {
MaterialTheme {
Box(Modifier.Companion.windowInsetsPadding(WindowInsets.Companion.safeDrawing)) {
when (model) {
is AppTemplate.FullScreenTemplate -> FullScreen(model)
is AppTemplate.HeaderDetailTemplate -> HeaderDetail(model)
}
}
}
}
@Composable
private fun FullScreen(template: AppTemplate.FullScreenTemplate) {
val renderer = rendererFactory.getComposeRenderer(template.model)
renderer.renderCompose(template.model)
}
@Composable
private fun HeaderDetail(template: AppTemplate.HeaderDetailTemplate) {
Column {
Row(Modifier.Companion.weight(1f)) {
rendererFactory.getComposeRenderer(template.header).renderCompose(template.header)
}
Row(Modifier.Companion.weight(5f)) {
rendererFactory.getComposeRenderer(template.detail).renderCompose(template.detail)
}
}
}
}
================================================
FILE: blueprints/starter/templates/public/build.gradle.kts
================================================
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.appPlatform)
alias(libs.plugins.androidKmpLibrary)
alias(libs.plugins.kotlinMultiplatform)
}
appPlatform {
enableModuleStructure(true)
enableMetro(true)
enableMoleculePresenters(true)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
androidLibrary {
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
wasmJs {
outputModuleName = project.path.removePrefix(":").replace(":", "-")
browser()
}
}
================================================
FILE: blueprints/starter/templates/public/src/commonMain/kotlin/software/amazon/app/platform/template/templates/AppTemplate.kt
================================================
package software.amazon.app.platform.template.templates
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.presenter.template.Template
/** All [Template]s implemented in the sample application. */
sealed interface AppTemplate : Template {
/** A template that hosts a single model, which should rendered as full-screen element. */
data class FullScreenTemplate(
/** The model to be rendered fullscreen. */
val model: BaseModel
) : AppTemplate
data class HeaderDetailTemplate(val header: BaseModel, val detail: BaseModel) : AppTemplate
}
================================================
FILE: blueprints/starter/templates/public/src/commonMain/kotlin/software/amazon/app/platform/template/templates/AppTemplatePresenter.kt
================================================
package software.amazon.app.platform.template.templates
import androidx.compose.runtime.Composable
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
import software.amazon.app.platform.presenter.molecule.returningCompositionLocalProvider
import software.amazon.app.platform.presenter.template.ModelDelegate
import software.amazon.app.platform.presenter.template.toTemplate
/**
* A presenter that wraps any other presenter and turns the emitted models from the other presenter
* into [AppTemplate]s.
*
* Inject [Factory] to create a new instance of [AppTemplatePresenter].
*/
@AssistedInject
class AppTemplatePresenter(@Assisted private val rootPresenter: MoleculePresenter) :
MoleculePresenter {
@Composable
override fun present(input: Unit): AppTemplate {
@Suppress("RemoveEmptyParenthesesFromLambdaCall")
return returningCompositionLocalProvider(
// Add local composition providers if needed.
) {
rootPresenter.present(Unit).toTemplate { AppTemplate.FullScreenTemplate(it) }
}
}
/** A factory to instantiate a new [AppTemplatePresenter] instance. */
@AssistedFactory
fun interface Factory {
/**
* Create a new [AppTemplatePresenter]. The given [presenter] will be wrapped and its models are
* transformed into a [AppTemplate] with [AppTemplate.FullScreenTemplate] as default. The given
* [presenter] can override the template by either returning [AppTemplate] directly or making
* its [BaseModel] type implement [ModelDelegate].
*/
fun createAppTemplatePresenter(rootPresenter: MoleculePresenter): AppTemplatePresenter
}
}
================================================
FILE: build.gradle
================================================
plugins {
id 'software.amazon.app.platform.root'
}
================================================
FILE: buildSrc/build.gradle
================================================
//file:noinspection UnnecessaryQualifiedReference
plugins {
id 'java-gradle-plugin'
alias libs.plugins.kotlin.jvm
alias libs.plugins.ktfmt
alias libs.plugins.build.config
}
buildConfig {
buildConfigField(String, 'APP_PLATFORM_GROUP', property('GROUP'))
}
ktfmt {
googleStyle()
trailingCommaManagementStrategy.set(com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy.COMPLETE)
removeUnusedImports.set(true)
}
java {
sourceCompatibility = libs.versions.jvm.buildsrc.get()
targetCompatibility = libs.versions.jvm.buildsrc.get()
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(libs.versions.jvm.buildsrc.get()))
}
explicitApi()
}
gradlePlugin {
plugins {
appPlatformAppPlugin {
id = "software.amazon.app.platform.app"
displayName = "App Platform App Gradle Plugin"
implementationClass = "software.amazon.app.platform.gradle.buildsrc.AppPlugin"
description = "The Gradle convention plugin for app modules."
}
appPlatformLibPlugin {
id = "software.amazon.app.platform.lib"
displayName = "App Platform Library Gradle Plugin"
implementationClass = "software.amazon.app.platform.gradle.buildsrc.LibraryPlugin"
description = "The Gradle convention plugin for library modules."
}
appPlatformJvmLibPlugin {
id = "software.amazon.app.platform.lib.jvm"
displayName = "App Platform JVM Library Gradle Plugin"
implementationClass = "software.amazon.app.platform.gradle.buildsrc.JvmLibraryPlugin"
description = "The Gradle convention plugin for JVM library modules."
}
appPlatformRootPlugin {
id = "software.amazon.app.platform.root"
displayName = "App Platform Root Gradle Plugin"
implementationClass = "software.amazon.app.platform.gradle.buildsrc.RootPlugin"
description = "The Gradle convention plugin for the root module."
}
}
}
dependencies {
implementation libs.android.gradle.plugin.api
implementation libs.android.gradle.plugin.asProvider()
implementation libs.compose.gradle.plugin
implementation libs.kotlin.gradle.plugin.api
implementation libs.kotlin.multiplatform.gradle.plugin
implementation libs.kotlin.compose.gradle.plugin
implementation libs.kotlinx.binaryCompatibilityValidator
runtimeOnly libs.kotlin.gradle.plugin.asProvider()
// This is needed to reference KspExperimental for experimental features.
compileOnly libs.ksp.api
implementation libs.ksp.gradle.plugin
implementation libs.graphviz.java
implementation libs.kotlin.hierarchy.plugin
implementation libs.ktfmt.gradle.plugin
implementation libs.maven.publish.gradle.plugin
implementation libs.metro.gradle.plugin
implementation libs.detekt.gradle.plugin
implementation "$GROUP:gradle-plugin:$VERSION_NAME"
}
tasks.register('release') {
dependsOn('build', 'check', 'ktfmtCheck')
}
================================================
FILE: buildSrc/settings.gradle
================================================
includeBuild('../gradle-plugin') {
dependencySubstitution {
substitute module("$GROUP:gradle-plugin") using project(':')
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
google()
gradlePluginPortal()
maven {
url = "https://central.sonatype.com/repository/maven-snapshots/"
}
}
versionCatalogs {
libs {
from files('../gradle/libs.versions.toml')
}
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/AppPlatformExtension.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import javax.inject.Inject
import org.gradle.api.Project
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import software.amazon.app.platform.gradle.AppPlatformExtension as AppPlatformExtensionGradlePlugin
import software.amazon.app.platform.gradle.buildsrc.BaseAndroidPlugin.Companion.enableInstrumentedTests
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.enableCompose
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.enableKotlinInject
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.enableMetro
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.enableMolecule
import software.amazon.app.platform.gradle.buildsrc.SdkPlugin.publishSdk
@Suppress("unused")
public open class AppPlatformExtension
@Inject
constructor(objects: ObjectFactory, private val project: Project) {
private val enableCompose: Property =
objects.property(Boolean::class.java).convention(false)
public fun enableCompose(enabled: Boolean) {
enableCompose.set(enabled)
enableCompose.disallowChanges()
if (enabled) {
project.enableCompose()
}
}
internal fun isComposeEnabled(): Property = enableCompose
private val enableKotlinInject: Property =
objects.property(Boolean::class.java).convention(false)
public fun enableKotlinInject(enabled: Boolean) {
enableKotlinInject.set(enabled)
enableKotlinInject.disallowChanges()
if (enabled) {
project.enableKotlinInject()
}
}
internal fun isKotlinInjectEnabled(): Property = enableKotlinInject
private val enableMetro: Property =
objects.property(Boolean::class.java).convention(false)
public fun enableMetro(enabled: Boolean) {
enableMetro.set(enabled)
enableMetro.disallowChanges()
if (enabled) {
project.enableMetro()
}
}
internal fun isMetroEnabled(): Property = enableMetro
private val enableMolecule: Property =
objects.property(Boolean::class.java).convention(false)
public fun enableMolecule(enabled: Boolean) {
enableMolecule.set(enabled)
enableMolecule.disallowChanges()
if (enabled) {
project.enableMolecule()
}
}
internal fun isMoleculeEnabled(): Property = enableMolecule
private val enablePublishing: Property =
objects.property(Boolean::class.java).convention(false)
public fun enablePublishing(enabled: Boolean) {
enablePublishing.set(enabled)
enablePublishing.disallowChanges()
if (enabled) {
project.publishSdk()
}
}
internal fun isPublishingEnabled(): Property = enablePublishing
private val kotlinWarningsAsErrors: Property =
objects
.property(Boolean::class.java)
.convention(
project.provider {
project.ci || project.gradle.taskGraph.hasTask("${project.path}:release")
}
)
public fun kotlinWarningsAsErrors(enabled: Boolean) {
kotlinWarningsAsErrors.set(enabled)
kotlinWarningsAsErrors.finalizeValueOnRead()
}
internal fun isKotlinWarningsAsErrors(): Property = kotlinWarningsAsErrors
private val enableInstrumentedTests: Property =
objects.property(Boolean::class.java).convention(false)
public fun enableInstrumentedTests(enabled: Boolean) {
if (enableInstrumentedTests.get() == enabled) {
return
}
enableInstrumentedTests.set(enabled)
enableInstrumentedTests.disallowChanges()
if (enabled) {
project.enableInstrumentedTests()
}
}
internal fun isInstrumentedTestsEnabled(): Property = enableInstrumentedTests
internal companion object {
val Project.appPlatformBuildSrc: AppPlatformExtension
get() = extensions.getByType(AppPlatformExtension::class.java)
val Project.appPlatformGradlePlugin: AppPlatformExtensionGradlePlugin
get() = extensions.getByType(AppPlatformExtensionGradlePlugin::class.java)
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/AppPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import kotlin.math.max
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.util.internal.VersionNumber
import org.jetbrains.compose.desktop.DesktopExtension
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
import software.amazon.app.platform.gradle.AppPlatformPlugin
import software.amazon.app.platform.gradle.buildsrc.AppPlugin.App.Companion.app
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.composeMultiplatform
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.kmpExtension
import software.amazon.app.platform.gradle.isAppModule
import software.amazon.app.platform.gradle.isRobotsModule
import software.amazon.app.platform.gradle.isTestingModule
public open class AppPlugin : Plugin {
override fun apply(target: Project) {
target.plugins.apply(Plugins.ANDROID_APP)
target.plugins.apply(BasePlugin::class.java)
target.plugins.apply(KmpPlugin::class.java)
target.plugins.apply(BaseAndroidPlugin::class.java)
target.configureAndroidSettings()
target.makeSingleVariant()
target.addDependencies()
target.configureWasm()
target.plugins.withId(Plugins.COMPOSE_MULTIPLATFORM) { target.configureDesktopApp() }
}
private fun Project.configureAndroidSettings() {
android.defaultConfig.minSdk = 26
}
private fun Project.makeSingleVariant() {
// Disable the release build type in the app module. We only need one build type
// and everything else is overhead.
androidComponents.beforeVariants { variant ->
if (variant.buildType != "debug") {
variant.enable = false
}
}
}
private fun Project.addDependencies() {
// iOS exports these dependencies for the iOS Framework and requires them to be added as
// "api" dependency to the project.
allExportedDependencies().forEach { dependency ->
kmpExtension.sourceSets.getByName("commonMain").dependencies { api(dependency) }
}
}
@OptIn(ExperimentalWasmDsl::class)
private fun Project.configureWasm() {
// For development use the Gradle task 'wasmJsBrowserDevelopmentRun'.
//
// Release builds are built with 'wasmJsBrowserDistribution'. To test the release run
// 'npx http-server' from the folder 'sample/app/build/dist/wasmJs/productionExecutable'.
// Keep references to the Project outside of the lambdas below, otherwise this will break
// the configuration cache.
val jsFileName = app.jsFileName
val outputName = safePathString
kmpExtension.wasmJs {
browser {
outputModuleName.set(outputName)
commonWebpackConfig {
it.outputFileName = jsFileName
it.devServer = it.devServer ?: KotlinWebpackConfig.DevServer()
}
}
binaries.executable()
}
}
internal companion object {
fun Project.allExportedDependencies(): Set {
return AppPlatformPlugin.exportedDependencies()
.plus(
project(app.rootProjectPath)
.subprojects
.filter { it.subprojects.isEmpty() }
.filter { !it.isRobotsModule() && !it.isTestingModule() && !it.isAppModule() }
)
}
fun Project.configureDesktopApp() {
composeMultiplatform.extensions.getByType(DesktopExtension::class.java).application.apply {
mainClass = app.desktopMainFile
nativeDistributions.targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
nativeDistributions.packageName = "software.amazon.app.platform.demo"
// During development the major version is 0, e.g. '0.0.1'. DMG must use a
// major version equal or greater than 1:
//
// Illegal version for 'Dmg': '0.0.1' is not a valid build version.
val version = VersionNumber.parse(versionName)
nativeDistributions.packageVersion =
VersionNumber(max(1, version.major), version.minor, version.patch, null).toString()
}
}
}
internal enum class App(val rootProjectPath: String) {
RECIPES(":recipes"),
SAMPLE(":sample");
val iosFrameworkName: String = rootProjectPath.substring(1).capitalize() + "App"
val jsFileName: String = rootProjectPath.substring(1) + "-app.js"
val desktopMainFile: String =
"software.amazon.app.platform.${rootProjectPath.substring(1)}.MainKt"
companion object {
val Project.app: App
get() {
check(isAppModule())
return when (path) {
":recipes:app" -> RECIPES
":sample:app" -> SAMPLE
else -> throw NotImplementedError()
}
}
}
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/BaseAndroidPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
public open class BaseAndroidPlugin : Plugin {
override fun apply(target: Project) {
target.configureAndroid()
}
private fun Project.configureAndroid() {
val android = android
android.compileSdk = libs.findVersion("android.compileSdk").get().requiredVersion.toInt()
android.defaultConfig.minSdk = libs.findVersion("android.minSdk").get().requiredVersion.toInt()
when (android) {
is LibraryExtension -> {
android.lint.targetSdk = libs.findVersion("android.targetSdk").get().requiredVersion.toInt()
android.testOptions.targetSdk =
libs.findVersion("android.targetSdk").get().requiredVersion.toInt()
android.defaultConfig.multiDexEnabled = true
}
is ApplicationExtension -> {
android.defaultConfig {
targetSdk = libs.findVersion("android.targetSdk").get().requiredVersion.toInt()
multiDexEnabled = true
applicationId = "software.amazon.app.platform.demo"
versionCode = 1
versionName = this@configureAndroid.versionName
}
}
}
android.packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
android.buildTypes.getByName("release").isMinifyEnabled = false
android.compileOptions.sourceCompatibility = javaVersion
android.compileOptions.targetCompatibility = javaVersion
android.testOptions.unitTests {
// Disable including Android resources in tests. None of our modules need them and it avoids
// running into issues with Gradle 9: https://issuetracker.google.com/issues/411739086
isIncludeAndroidResources = false
isReturnDefaultValues = true
}
android.lint {
warningsAsErrors = true
htmlReport = true
disable +=
setOf(
"GradleDependency",
"ObsoleteLintCustomCheck",
"NewerVersionAvailable",
"AndroidGradlePluginVersion",
"OldTargetApi",
)
}
releaseTask.configure { it.dependsOn("lintDebug") }
}
internal companion object {
internal fun Project.enableInstrumentedTests() {
releaseTask.configure {
it.dependsOn("assembleDebugAndroidTest")
it.dependsOn("emulatorCheck")
}
android.defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments += "clearPackageData" to "true"
}
android.testOptions.execution = "ANDROIDX_TEST_ORCHESTRATOR"
dependencies.add(
"androidTestUtil",
libs.findLibrary("androidx.test.orchestrator").get().get().toString(),
)
dependencies.add(
"androidTestImplementation",
libs.findLibrary("androidx.test.runner").get().get().toString(),
)
dependencies.add(
"androidTestImplementation",
libs.findLibrary("androidx.test.rules").get().get().toString(),
)
dependencies.add(
"androidTestImplementation",
libs.findLibrary("androidx.test.junit").get().get().toString(),
)
dependencies.add(
"androidTestImplementation",
libs.findLibrary("kotlin.test").get().get().toString(),
)
dependencies.add(
"androidTestImplementation",
libs.findLibrary("assertk").get().get().toString(),
)
@Suppress("UnstableApiUsage")
android.testOptions.managedDevices.localDevices.create("emulator") {
// Use device profiles you typically see in Android Studio.
it.device = "Pixel 3"
it.apiLevel = 30
it.require64Bit = true
it.systemImageSource = "aosp-atd"
}
}
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/BasePlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import buildSrc.BuildConfig.APP_PLATFORM_GROUP
import com.android.build.gradle.internal.tasks.factory.dependsOn
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.jetbrains.kotlin.gradle.targets.web.yarn.BaseYarnRootExtension
import org.jetbrains.kotlin.gradle.targets.web.yarn.CommonYarnPlugin
import software.amazon.app.platform.gradle.buildsrc.AppPlatformExtension.Companion.appPlatformGradlePlugin
public open class BasePlugin : Plugin {
override fun apply(target: Project) {
target.createReleaseTask()
target.configureDependencySubstitution()
// We're dogfooding our published Gradle plugin in the :app module. The extension names
// are conflicting, therefore use another name than "appPlatform".
target.extensions.create("appPlatformBuildSrc", AppPlatformExtension::class.java)
target.addAppPlatformGradlePlugin()
target.runTestsInHeadlessMode()
target.configureLogOutput()
target.upgradeYarnDependencies()
}
private fun Project.createReleaseTask() {
tasks.register("release")
}
private fun Project.runTestsInHeadlessMode() {
// Otherwise the java icon keeps popping up in the system tray while running tests.
tasks.withType(Test::class.java).configureEach {
it.systemProperty("java.awt.headless", "true")
}
}
private fun Project.configureLogOutput() {
if (ci) {
tasks.withType(Test::class.java).configureEach { testTask ->
testTask.testLogging {
it.showExceptions = true
it.showCauses = true
it.showStackTraces = true
it.showStandardStreams = true
}
}
}
}
private fun Project.configureDependencySubstitution() {
// In some modules we apply the App Platform Gradle plugin, which adds dependencies to
// these pre-built binaries. Here we tell Gradle to replace the pre-built binaries with
// the Gradle modules and build the code on the fly. See settings.gradle for more details
// as well.
val substitutions =
mapOf(
"${APP_PLATFORM_GROUP}:di-common-public" to ":di-common:public",
"${APP_PLATFORM_GROUP}:kotlin-inject-public" to ":kotlin-inject:public",
"${APP_PLATFORM_GROUP}:kotlin-inject-contribute-impl-code-generators" to
":kotlin-inject-extensions:contribute:impl-code-generators",
"${APP_PLATFORM_GROUP}:kotlin-inject-contribute-public" to
":kotlin-inject-extensions:contribute:public",
"${APP_PLATFORM_GROUP}:kotlin-inject-impl" to ":kotlin-inject:impl",
"${APP_PLATFORM_GROUP}:ksp-common-public" to ":ksp-common:public",
"${APP_PLATFORM_GROUP}:metro-public" to ":metro:public",
"${APP_PLATFORM_GROUP}:metro-impl" to ":metro:impl",
"${APP_PLATFORM_GROUP}:metro-contribute-impl-compiler-plugin" to
":metro-extensions:contribute:impl-compiler-plugin",
"${APP_PLATFORM_GROUP}:metro-contribute-impl-code-generators" to
":metro-extensions:contribute:impl-code-generators",
"${APP_PLATFORM_GROUP}:presenter-public" to ":presenter:public",
"${APP_PLATFORM_GROUP}:presenter-molecule-public" to ":presenter-molecule:public",
"${APP_PLATFORM_GROUP}:presenter-molecule-impl" to ":presenter-molecule:impl",
"${APP_PLATFORM_GROUP}:presenter-molecule-testing" to ":presenter-molecule:testing",
"${APP_PLATFORM_GROUP}:renderer-public" to ":renderer:public",
"${APP_PLATFORM_GROUP}:renderer-android-view-public" to ":renderer-android-view:public",
"${APP_PLATFORM_GROUP}:renderer-compose-multiplatform-public" to
":renderer-compose-multiplatform:public",
"${APP_PLATFORM_GROUP}:robot-public" to ":robot:public",
"${APP_PLATFORM_GROUP}:robot-compose-multiplatform-public" to
":robot-compose-multiplatform:public",
"${APP_PLATFORM_GROUP}:robot-internal-public" to ":robot-internal:public",
"${APP_PLATFORM_GROUP}:scope-public" to ":scope:public",
"${APP_PLATFORM_GROUP}:scope-testing" to ":scope:testing",
)
plugins.withId(Plugins.MAVEN_PUBLISH) {
check(path in substitutions.values) {
"Forgot to setup dependency substitution for $path. Add a mapping in the " +
"substitution collection."
}
}
configurations.configureEach { configuration ->
configuration.resolutionStrategy.dependencySubstitution { substitution ->
substitutions.forEach { (module, project) ->
substitution.substitute(substitution.module(module)).using(substitution.project(project))
}
}
}
}
private fun Project.addAppPlatformGradlePlugin() {
if (!isRoot) {
plugins.apply(Plugins.APP_PLATFORM)
plugins.withIds(Plugins.KOTLIN_MULTIPLATFORM, Plugins.KOTLIN_JVM) {
appPlatformGradlePlugin.enableModuleStructure(true)
releaseTask.dependsOn("checkModuleStructureDependencies")
}
}
}
private fun Project.upgradeYarnDependencies() {
plugins.withType(CommonYarnPlugin::class.java).configureEach {
with(extensions.getByType(BaseYarnRootExtension::class.java)) {
// Force the newer version due to https://github.com/amzn/app-platform/security/dependabot/5
resolution("webpack-dev-server", "5.2.1")
// Force the newer version due to https://github.com/amzn/app-platform/security/dependabot/8
resolution("on-headers", "1.1.0")
// Force the newer version due to
// https://github.com/amzn/app-platform/security/dependabot/10
resolution("tmp", "0.2.4")
}
}
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/Gradle.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.variant.AndroidComponentsExtension
import java.util.Locale
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.plugins.PluginContainer
import org.gradle.api.tasks.TaskProvider
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import software.amazon.app.platform.gradle.moduleType
internal val Project.libs: VersionCatalog
get() = extensions.getByType(VersionCatalogsExtension::class.java).named("libs")
internal val Project.ci
get() = providers.gradleProperty("CI").isPresent || System.getenv("CI") != null
internal val Project.javaVersion
get() = JavaVersion.toVersion(libs.findVersion("jvm.compatibility").get().requiredVersion)
internal val Project.javaTarget
get() = JvmTarget.fromTarget(javaVersion.toString())
internal val Project.safePathString: String
get() = path.replace(':', '-').substring(1)
internal val Project.isKmpModule: Boolean
get() = plugins.hasPlugin(Plugins.KOTLIN_MULTIPLATFORM)
internal val Project.isRoot: Boolean
get() = path == ":"
internal val Project.android: CommonExtension<*, *, *, *, *, *>
get() = extensions.getByType(CommonExtension::class.java)
internal val Project.androidComponents: AndroidComponentsExtension<*, *, *>
get() = extensions.getByType(AndroidComponentsExtension::class.java)
internal val Project.releaseTask: TaskProvider
get() = tasks.named("release")
internal val Project.versionName: String
get() = requireNotNull(property("VERSION_NAME")).toString()
internal fun Project.useTestDependenciesInMain(): Boolean {
return moduleType.useTestDependenciesInMain || path.startsWith(":robot")
}
internal fun PluginContainer.withIds(vararg pluginIds: String, action: (Plugin<*>) -> Unit) {
pluginIds.forEach { id -> withId(id) { action(it) } }
}
internal fun String.capitalize(): String = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString()
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/JvmLibraryPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import software.amazon.app.platform.gradle.buildsrc.AppPlatformExtension.Companion.appPlatformBuildSrc
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.configureKtfmt
public open class JvmLibraryPlugin : Plugin {
override fun apply(target: Project) {
target.plugins.apply(BasePlugin::class.java)
target.plugins.apply(Plugins.KOTLIN_JVM)
target.configureKotlin()
target.configureTests()
target.configureCoroutines()
target.configureKtfmt()
}
private fun Project.configureKotlin() {
dependencies.add(
"api",
dependencies.platform(libs.findLibrary("kotlin.bom").get().get().toString()),
)
extensions.getByType(KotlinJvmProjectExtension::class.java).compilerOptions {
allWarningsAsErrors.set(appPlatformBuildSrc.isKotlinWarningsAsErrors())
jvmTarget.set(javaTarget)
}
with(extensions.getByType(JavaPluginExtension::class.java)) {
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
}
}
private fun Project.configureTests() {
releaseTask.configure { task -> task.dependsOn("test") }
dependencies.add("testImplementation", libs.findLibrary("kotlin.test").get().get().toString())
dependencies.add("testImplementation", libs.findLibrary("assertk").get().get().toString())
}
private fun Project.configureCoroutines() {
dependencies.add("implementation", libs.findLibrary("coroutines.core").get().get().toString())
dependencies.add(
"testImplementation",
libs.findLibrary("coroutines.test").get().get().toString(),
)
dependencies.add("testImplementation", libs.findLibrary("turbine").get().get().toString())
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/KmpPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import com.google.devtools.ksp.gradle.KspExtension
import com.ncorti.ktfmt.gradle.KtfmtExtension
import com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy
import guru.nidi.graphviz.engine.Format
import io.github.terrakok.KmpHierarchyConfig
import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.plugins.ExtensionAware
import org.gradle.api.tasks.SourceTask
import org.jetbrains.compose.ComposeExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.NATIVE_COMPILER_PLUGIN_CLASSPATH_CONFIGURATION_NAME
import org.jetbrains.kotlin.gradle.plugin.PLUGIN_CLASSPATH_CONFIGURATION_NAME
import software.amazon.app.platform.gradle.buildsrc.AppPlatformExtension.Companion.appPlatformBuildSrc
import software.amazon.app.platform.gradle.buildsrc.Platform.Companion.allPlatforms
public open class KmpPlugin : Plugin {
override fun apply(target: Project) {
target.plugins.apply(Plugins.KOTLIN_MULTIPLATFORM)
target.configureCommonKotlin()
target.configureCoroutines()
target.configureKtfmt()
target.configureTests()
target.configureDetekt()
target.addExtraSourceSets()
target.configureHierarchyPlugin()
}
private fun Project.configureCommonKotlin() {
kmpExtension.applyDefaultHierarchyTemplate()
dependencies.add(
"commonMainApi",
dependencies.platform(libs.findLibrary("kotlin.bom").get().get().toString()),
)
// Only for tests.
kmpExtension.sourceSets
.getByName("commonTest")
.languageSettings
.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
kmpExtension.compilerOptions {
freeCompilerArgs.add("-Xannotation-default-target=param-property")
// Unfortunately, we cannot set this to true. It produces warnings for generated code,
// which cannot be excluded.
extraWarnings.set(false)
allWarningsAsErrors.set(appPlatformBuildSrc.isKotlinWarningsAsErrors())
}
kmpExtension.targets.configureEach { target ->
target.compilations.configureEach { compilation ->
compilation.compileTaskProvider.configure { task ->
with(task.compilerOptions) {
if ("test" in task.name.lowercase() || path == ":internal:testing") {
freeCompilerArgs.add("-Xexpect-actual-classes")
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
// We need to rename the KLib library for iOS to avoid duplicate names. By
// default project.name is used, which conflicts with our module structure
// where many modules are named "public" or "impl". If that happens during
// compilation only code from one module is found.
//
// There is currently no DSL to set the KLib name. For more details see
// https://youtrack.jetbrains.com/issue/KT-38719
// https://youtrack.jetbrains.com/issue/KT-38892
if (target.targetName != "js" && target.targetName != "wasmJs") {
// Note this doesn't work on JS/WASMJS yet due to
// https://youtrack.jetbrains.com/issue/KT-71362
freeCompilerArgs.add("-module-name")
freeCompilerArgs.add("$safePathString.${compilation.compilationName}")
}
}
}
}
}
allPlatforms().forEach { platform -> platform.configurePlatform() }
}
private fun Project.configureCoroutines() {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation(libs.findLibrary("coroutines.core").get().get().toString())
}
testingSourceSets.forEach { sourceSetName ->
kmpExtension.sourceSets.getByName(sourceSetName).dependencies {
// Use api for main source sets (testing utility modules) so downstream modules
// get transitive access. Use implementation for test source sets since api is
// deprecated there in Kotlin 2.3.
val isTestSourceSet = sourceSetName.contains("Test", ignoreCase = true)
if (isTestSourceSet) {
implementation(libs.findLibrary("coroutines.test").get().get().toString())
implementation(libs.findLibrary("turbine").get().get().toString())
} else {
api(libs.findLibrary("coroutines.test").get().get().toString())
api(libs.findLibrary("turbine").get().get().toString())
}
}
}
allPlatforms().forEach { platform -> platform.configureCoroutines() }
}
private fun Project.configureTests() {
testingSourceSets.forEach { sourceSetName ->
kmpExtension.sourceSets.getByName(sourceSetName).dependencies {
val isTestSourceSet = sourceSetName.contains("Test", ignoreCase = true)
if (isTestSourceSet) {
implementation(kotlin("test"))
implementation(libs.findLibrary("assertk").get().get().toString())
} else {
api(kotlin("test"))
api(libs.findLibrary("assertk").get().get().toString())
}
}
}
releaseTask.configure { task ->
task.dependsOn(allPlatforms().mapNotNull { it.unitTestTaskName })
}
}
private fun Project.addExtraSourceSets() {
val platforms = allPlatforms()
if (platforms.any { it is Platform.Ios } && platforms.any { it is Platform.DesktopPlatform }) {
setOf("Main", "Test").forEach { suffix ->
val common = kmpExtension.sourceSets.getByName("common$suffix")
val appleAndDesktop = kmpExtension.sourceSets.create("appleAndDesktop$suffix")
appleAndDesktop.dependsOn(common)
kmpExtension.sourceSets.named("apple$suffix").configure { it.dependsOn(appleAndDesktop) }
kmpExtension.sourceSets.named("desktop$suffix").configure { it.dependsOn(appleAndDesktop) }
val noWasmJs = kmpExtension.sourceSets.create("noWasmJs$suffix")
noWasmJs.dependsOn(common)
appleAndDesktop.dependsOn(noWasmJs)
kmpExtension.sourceSets.named("native$suffix").configure { it.dependsOn(noWasmJs) }
if (suffix == "Main") {
kmpExtension.sourceSets.named("android$suffix").configure { it.dependsOn(noWasmJs) }
} else {
kmpExtension.sourceSets.named("androidUnit$suffix").configure { it.dependsOn(noWasmJs) }
}
}
}
}
private fun Project.configureHierarchyPlugin() {
plugins.apply(Plugins.KOTLIN_HIERARCHY)
(extensions.getByType(KotlinMultiplatformExtension::class.java) as ExtensionAware)
.extensions
.getByType(KmpHierarchyConfig::class.java)
.run {
formats(Format.PNG, Format.SVG)
withTestHierarchy = true
}
}
internal companion object {
val Project.kmpExtension: KotlinMultiplatformExtension
get() = extensions.getByType(KotlinMultiplatformExtension::class.java)
val Project.composeMultiplatform: ComposeExtension
get() = extensions.getByType(ComposeExtension::class.java)
fun Project.enableCompose() {
plugins.apply(Plugins.COMPOSE_COMPILER)
plugins.apply(Plugins.COMPOSE_MULTIPLATFORM)
val composeVersion = libs.findVersion("compose.multiplatform").get().requiredVersion
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
}
allPlatforms().forEach { platform -> platform.configureCompose() }
}
fun Project.enableKotlinInject() {
enableKsp()
val kspExtension = extensions.getByType(KspExtension::class.java)
// Disable this processor, because we implement our own version in order to support the
// Scoped interface.
kspExtension.arg(
"software.amazon.lastmile.kotlin.inject.anvil.processor." + "ContributesBindingProcessor",
"disabled",
)
if (isKmpModule) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation(libs.findLibrary("kotlin.inject.runtime").get().get().toString())
implementation(libs.findLibrary("kotlin.inject.anvil.runtime").get().get().toString())
implementation(
libs.findLibrary("kotlin.inject.anvil.runtime.optional").get().get().toString()
)
if (path != ":di-common:public" && path != ":kotlin-inject:public") {
implementation(project(":di-common:public"))
implementation(project(":kotlin-inject:public"))
if (!path.startsWith(":kotlin-inject-extensions:contribute:")) {
implementation(project(":kotlin-inject-extensions:contribute:public"))
}
}
}
} else {
dependencies.add(
"implementation",
libs.findLibrary("kotlin.inject.runtime").get().get().toString(),
)
dependencies.add(
"implementation",
libs.findLibrary("kotlin.inject.anvil.runtime").get().get().toString(),
)
dependencies.add(
"implementation",
libs.findLibrary("kotlin.inject.anvil.runtime.optional").get().get().toString(),
)
if (path != ":di-common:public" && path != ":kotlin-inject:public") {
dependencies.add("implementation", project(":di-common:public"))
dependencies.add("implementation", project(":kotlin-inject:public"))
if (!path.startsWith(":kotlin-inject-extensions:contribute:")) {
dependencies.add(
"implementation",
project(":kotlin-inject-extensions:contribute:public"),
)
}
}
}
fun DependencyHandler.addKspProcessorDependencies(kspConfigurationName: String) {
add(kspConfigurationName, libs.findLibrary("kotlin.inject.ksp").get().get().toString())
add(
kspConfigurationName,
libs.findLibrary("kotlin.inject.anvil.compiler").get().get().toString(),
)
// Avoid creating a circular dependency.
if (
path != ":di-common:public" &&
path != ":kotlin-inject:public" &&
!path.startsWith(":kotlin-inject-extensions:contribute:")
) {
add(kspConfigurationName, project(":kotlin-inject-extensions:contribute:public"))
add(
kspConfigurationName,
project(":kotlin-inject-extensions:contribute:impl-code-generators"),
)
}
}
if (isKmpModule) {
kmpExtension.targets.configureEach {
if (it.name != "metadata") {
dependencies.addKspProcessorDependencies("ksp${it.name.capitalize()}")
dependencies.addKspProcessorDependencies("ksp${it.name.capitalize()}Test")
}
}
} else {
dependencies.addKspProcessorDependencies("ksp")
}
}
fun Project.enableMetro() {
plugins.apply(Plugins.METRO)
val useMetroKsp =
providers
.gradleProperty("app.platform.metro.ksp")
.map(String::toBoolean)
.orElse(false)
.get()
if (useMetroKsp) {
enableMetroKsp()
} else {
enableMetroCompilerPlugin()
}
}
private fun Project.enableMetroKsp() {
enableKsp()
if (isKmpModule) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation(project(":di-common:public"))
implementation(project(":metro:public"))
}
} else {
dependencies.add("implementation", project(":metro:public"))
}
fun DependencyHandler.addKspProcessorDependencies(kspConfigurationName: String) {
add(kspConfigurationName, project(":metro-extensions:contribute:impl-code-generators"))
}
if (isKmpModule) {
kmpExtension.targets.configureEach {
if (it.name != "metadata") {
dependencies.addKspProcessorDependencies("ksp${it.name.capitalize()}")
dependencies.addKspProcessorDependencies("ksp${it.name.capitalize()}Test")
}
}
} else {
dependencies.addKspProcessorDependencies("ksp")
}
}
private fun Project.enableMetroCompilerPlugin() {
if (isKmpModule) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation(project(":di-common:public"))
implementation(project(":metro:public"))
}
} else {
dependencies.add("implementation", project(":metro:public"))
}
fun DependencyHandler.addCompilerPluginDependencies() {
add(
PLUGIN_CLASSPATH_CONFIGURATION_NAME,
project(":metro-extensions:contribute:impl-compiler-plugin"),
)
add(
NATIVE_COMPILER_PLUGIN_CLASSPATH_CONFIGURATION_NAME,
project(":metro-extensions:contribute:impl-compiler-plugin"),
)
}
plugins.withId(Plugins.KOTLIN_MULTIPLATFORM) { dependencies.addCompilerPluginDependencies() }
plugins.withId(Plugins.KOTLIN_JVM) { dependencies.addCompilerPluginDependencies() }
}
private fun Project.enableKsp() {
plugins.apply(Plugins.KSP)
}
fun Project.enableMolecule() {
plugins.apply(Plugins.COMPOSE_COMPILER)
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation(libs.findLibrary("molecule.runtime").get().get().toString())
}
}
fun Project.configureKtfmt() {
plugins.apply(Plugins.KTFMT)
extensions.getByType(KtfmtExtension::class.java).apply {
googleStyle()
trailingCommaManagementStrategy.set(TrailingCommaManagementStrategy.COMPLETE)
removeUnusedImports.set(true)
}
releaseTask.configure { releaseTask -> releaseTask.dependsOn("ktfmtCheck") }
}
private fun Project.configureDetekt() {
plugins.apply(Plugins.DETEKT)
fun SourceTask.configureDefaultDetektTask() {
// The :detekt task in a multiplatform project doesn't do anything, it has no
// sources configured. Instead, the Detekt plugin creates a Gradle task for each
// source set, which then need to be called manually. This is annoying and tedious.
//
// We make the default :detekt task analyze all .kt files, which is faster,
// because only a single task runs, and we avoid all the wiring.
setSource(layout.files("src"))
exclude("**/*.kts")
exclude("**/api/**")
exclude("**/build/**")
exclude("**/detekt/**")
}
// Make Detekt use the right version of Java
tasks.withType(Detekt::class.java).configureEach { detekt ->
detekt.jvmTarget = javaVersion.toString()
if (detekt.name == "detekt") {
detekt.configureDefaultDetektTask()
}
}
tasks.withType(DetektCreateBaselineTask::class.java).configureEach {
it.jvmTarget = javaVersion.toString()
if (it.name == "detektBaseline") {
it.configureDefaultDetektTask()
}
}
with(extensions.getByType(DetektExtension::class.java)) {
// From the Groovy DSL at https://detekt.github.io/detekt/gradle.html#groovy-dsl-3
// This produces baselines named "detekt-baseline.xml"
baseline = file("detekt/detekt-baseline.xml")
// Config overrides
config.from(rootProject.file("gradle/detekt-config.yml"))
buildUponDefaultConfig = true
}
releaseTask.configure { releaseTask -> releaseTask.dependsOn("detekt") }
}
private val Project.testingSourceSets
get() = buildList {
add("commonTest")
if (useTestDependenciesInMain()) {
add("commonMain")
}
}
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/LibraryPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import org.gradle.api.Plugin
import org.gradle.api.Project
public open class LibraryPlugin : Plugin {
override fun apply(target: Project) {
target.plugins.apply(BasePlugin::class.java)
target.plugins.apply(Plugins.ANDROID_LIBRARY)
target.plugins.apply(KmpPlugin::class.java)
target.plugins.apply(BaseAndroidPlugin::class.java)
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/Platform.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import software.amazon.app.platform.gradle.buildsrc.AppPlugin.App.Companion.app
import software.amazon.app.platform.gradle.buildsrc.AppPlugin.Companion.allExportedDependencies
import software.amazon.app.platform.gradle.buildsrc.KmpPlugin.Companion.kmpExtension
import software.amazon.app.platform.gradle.isAppModule
internal sealed interface Platform {
val unitTestTaskName: String?
fun configurePlatform()
fun configureCoroutines() = Unit
fun configureCompose() = Unit
abstract class Native : Platform {
protected abstract val project: Project
}
abstract class Ios : Native() {
abstract val target: KotlinNativeTarget
override fun configurePlatform() {
target.binaries.framework {
baseName =
if (project.isAppModule()) {
project.app.iosFrameworkName
} else {
project.safePathString.capitalize()
}
isStatic = project.isAppModule()
if (project.isAppModule()) {
project.allExportedDependencies().forEach { dependency -> export(dependency) }
}
}
}
}
private class AndroidPlatform(private val project: Project) : Platform {
override val unitTestTaskName: String = "testDebugUnitTest"
override fun configurePlatform() {
project.kmpExtension.androidTarget().compilerOptions { jvmTarget.set(project.javaTarget) }
project.android.sourceSets.getByName("main").apply {
project
.file("src/androidMain/AndroidManifest.xml")
.takeIf { it.exists() }
?.let { manifest.srcFile(it) }
project.file("src/androidMain/res").takeIf { it.exists() }?.let { res.srcDirs(it) }
project
.file("src/commonMain/resources")
.takeIf { it.exists() }
?.let { resources.srcDirs(it) }
}
}
}
class DesktopPlatform(private val project: Project) : Platform {
override val unitTestTaskName: String = "desktopTest"
override fun configurePlatform() {
project.kmpExtension.jvm("desktop").compilerOptions { jvmTarget.set(project.javaTarget) }
with(project.extensions.getByType(JavaPluginExtension::class.java)) {
sourceCompatibility = project.javaVersion
targetCompatibility = project.javaVersion
}
}
override fun configureCoroutines() {
project.kmpExtension.sourceSets.getByName("desktopMain").dependencies {
implementation(project.libs.findLibrary("coroutines.swing").get().get().toString())
}
}
override fun configureCompose() {
val composeVersion = project.libs.findVersion("compose.multiplatform").get().requiredVersion
project.kmpExtension.sourceSets.getByName("desktopMain").dependencies {
implementation(
"org.jetbrains.compose.desktop:desktop-jvm-${currentOsTarget()}:$composeVersion"
)
}
project.kmpExtension.sourceSets.getByName("desktopTest").dependencies {
implementation("org.jetbrains.compose.ui:ui-test-junit4:$composeVersion")
implementation(
"org.jetbrains.compose.desktop:desktop-jvm-${currentOsTarget()}:$composeVersion"
)
}
}
private fun currentOsTarget(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
return when {
os.contains("mac") || os.contains("darwin") ->
if (arch.contains("aarch64") || arch.contains("arm")) "macos-arm64" else "macos-x64"
os.contains("win") ->
if (arch.contains("aarch64") || arch.contains("arm")) "windows-arm64" else "windows-x64"
else -> if (arch.contains("aarch64") || arch.contains("arm")) "linux-arm64" else "linux-x64"
}
}
}
private abstract class Linux : Platform {
abstract val project: Project
abstract val target: KotlinNativeTarget
override fun configurePlatform() {
target.binaries { sharedLib { baseName = project.safePathString.capitalize() } }
}
}
private class LinuxArm64(override val project: Project) : Linux() {
// Tests aren't supported, because the KMP Gradle plugin doesn't generate the Gradle tasks.
override val unitTestTaskName: String? = null
override val target: KotlinNativeTarget by lazy { project.kmpExtension.linuxArm64() }
}
private class LinuxX64(override val project: Project) : Linux() {
override val unitTestTaskName = "linuxX64Test"
override val target: KotlinNativeTarget by lazy { project.kmpExtension.linuxX64() }
}
private class IosSimulatorArm64(override val project: Project) : Ios() {
override val unitTestTaskName: String = "iosSimulatorArm64Test"
override val target: KotlinNativeTarget by lazy { project.kmpExtension.iosSimulatorArm64() }
}
private class IosArm64(override val project: Project) : Ios() {
override val unitTestTaskName: String? = null
override val target: KotlinNativeTarget by lazy { project.kmpExtension.iosArm64() }
}
private class Wasm(private val project: Project) : Platform {
override val unitTestTaskName: String = "wasmJsTest"
override fun configurePlatform() {
@Suppress("OPT_IN_USAGE")
project.kmpExtension.wasmJs { browser { outputModuleName.set(project.safePathString) } }
}
}
companion object {
private val projectsUsingCompose =
setOf(":renderer-compose-multiplatform:public", ":robot-compose-multiplatform:public") +
AppPlugin.App.entries.map { it.rootProjectPath }
fun Project.allPlatforms(): Set = buildSet {
// Always add Android. It's our most important platform and buildable in all
// environments (locally and CI)
add(AndroidPlatform(project = this@allPlatforms))
// Android-only modules have "android" in their name and don't need other
// platforms.
if ("android" !in path.lowercase()) {
add(DesktopPlatform(project = this@allPlatforms))
add(IosSimulatorArm64(project = this@allPlatforms))
add(IosArm64(project = this@allPlatforms))
add(Wasm(project = this@allPlatforms))
// Compose Multiplatform does not support Linux, so exclude these modules.
if (projectsUsingCompose.none { path.startsWith(it) }) {
add(LinuxArm64(project = this@allPlatforms))
add(LinuxX64(project = this@allPlatforms))
}
}
}
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/Plugins.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
internal object Plugins {
const val ANDROID_APP = "com.android.application"
const val ANDROID_LIBRARY = "com.android.library"
const val APP_PLATFORM = "software.amazon.app.platform"
const val BINARY_COMPAT_VALIDATOR = "org.jetbrains.kotlinx.binary-compatibility-validator"
const val COMPOSE_COMPILER = "org.jetbrains.kotlin.plugin.compose"
const val COMPOSE_MULTIPLATFORM = "org.jetbrains.compose"
const val DETEKT = "io.gitlab.arturbosch.detekt"
const val KOTLIN_MULTIPLATFORM = "org.jetbrains.kotlin.multiplatform"
const val KOTLIN_HIERARCHY = "io.github.terrakok.kmp-hierarchy"
const val KOTLIN_JVM = "org.jetbrains.kotlin.jvm"
const val KSP = "com.google.devtools.ksp"
const val KTFMT = "com.ncorti.ktfmt.gradle"
const val MAVEN_PUBLISH = "com.vanniktech.maven.publish"
const val METRO = "dev.zacsweers.metro"
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/RootPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import org.gradle.api.Plugin
import org.gradle.api.Project
public open class RootPlugin : Plugin {
override fun apply(target: Project) {
target.plugins.apply(BasePlugin::class.java)
}
}
================================================
FILE: buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/SdkPlugin.kt
================================================
package software.amazon.app.platform.gradle.buildsrc
import com.vanniktech.maven.publish.MavenPublishBaseExtension
import kotlinx.validation.ApiValidationExtension
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import software.amazon.app.platform.gradle.ModuleStructurePlugin.Companion.artifactId
internal object SdkPlugin {
fun Project.publishSdk() {
mavenPublishing()
configureBinaryCompatibility()
configureExplicitApi()
}
private fun Project.mavenPublishing() {
// This plugin will add Gradle tasks to generate a source and javadoc .jar files, to
// generate the .pom file and to publish the binaries in the local maven repository and
// other repositories when needed.
plugins.apply(Plugins.MAVEN_PUBLISH)
// :presenter:public -> ${group}:presenter-public:${version}
// :presenter:impl -> ${group}:presenter-impl:${version}
// :presenter:testing -> ${group}:presenter-testing:${version}
val parent = requireNotNull(parent)
val artifactId =
when {
parent.name == "contribute" && parent.parent?.name == "kotlin-inject-extensions" -> {
// Change the artifact ID, because "contribute" alone is a weird name.
artifactId(libraryName = "kotlin-inject-contribute")
}
parent.name == "contribute" && parent.parent?.name == "metro-extensions" -> {
// Change the artifact ID, because "contribute" alone is a weird name.
artifactId(libraryName = "metro-contribute")
}
else -> {
artifactId()
}
}
mavenPublish.coordinates(artifactId = artifactId)
mavenPublish.pom { pom ->
pom.name.set(
"App Platform ${
artifactId.split('-')
.joinToString(separator = " ", prefix = "", postfix = "") { it.capitalize() }
}"
)
}
}
private fun Project.configureBinaryCompatibility() {
// This plugin ensures that binary changes are committed as a human readable text file
// in the repository.
plugins.apply(Plugins.BINARY_COMPAT_VALIDATOR)
releaseTask.configure { it.dependsOn("apiCheck") }
val apiValidation = extensions.getByType(ApiValidationExtension::class.java)
// Klib doesn't work in CI right now and this creates mismatch between local and CI builds.
// Disable the experimental feature for now.
@Suppress("OPT_IN_USAGE")
apiValidation.klib.enabled = false
// These packages only contain generated code that is picked up by compiler plugins.
// They don't need to be part of the API dumps.
apiValidation.ignoredPackages +=
setOf("app.platform.inject", "amazon.lastmile.inject", "metro.hints")
}
private fun Project.configureExplicitApi() {
extensions.getByType(KotlinBaseExtension::class.java).explicitApi()
}
private val Project.mavenPublish: MavenPublishBaseExtension
get() = extensions.getByType(MavenPublishBaseExtension::class.java)
}
================================================
FILE: di-common/public/api/android/public.api
================================================
public abstract interface annotation class software/amazon/app/platform/inject/ContributesRenderer : java/lang/annotation/Annotation {
public abstract fun includeSealedSubtypes ()Z
public abstract fun modelType ()Ljava/lang/Class;
}
public abstract interface annotation class software/amazon/app/platform/inject/robot/ContributesRobot : java/lang/annotation/Annotation {
public abstract fun scope ()Ljava/lang/Class;
}
public abstract interface annotation class software/amazon/app/platform/presenter/PresenterCoroutineScope : java/lang/annotation/Annotation {
}
public abstract interface annotation class software/amazon/app/platform/scope/coroutine/DefaultCoroutineDispatcher : java/lang/annotation/Annotation {
}
public abstract interface annotation class software/amazon/app/platform/scope/coroutine/IoCoroutineDispatcher : java/lang/annotation/Annotation {
}
public abstract interface annotation class software/amazon/app/platform/scope/coroutine/MainCoroutineDispatcher : java/lang/annotation/Annotation {
}
================================================
FILE: di-common/public/api/desktop/public.api
================================================
public abstract interface annotation class software/amazon/app/platform/inject/ContributesRenderer : java/lang/annotation/Annotation {
public abstract fun includeSealedSubtypes ()Z
public abstract fun modelType ()Ljava/lang/Class;
}
public abstract interface annotation class software/amazon/app/platform/inject/robot/ContributesRobot : java/lang/annotation/Annotation {
public abstract fun scope ()Ljava/lang/Class;
}
public abstract interface annotation class software/amazon/app/platform/presenter/PresenterCoroutineScope : java/lang/annotation/Annotation {
}
public abstract interface annotation class software/amazon/app/platform/scope/coroutine/DefaultCoroutineDispatcher : java/lang/annotation/Annotation {
}
public abstract interface annotation class software/amazon/app/platform/scope/coroutine/IoCoroutineDispatcher : java/lang/annotation/Annotation {
}
public abstract interface annotation class software/amazon/app/platform/scope/coroutine/MainCoroutineDispatcher : java/lang/annotation/Annotation {
}
================================================
FILE: di-common/public/build.gradle
================================================
plugins {
id 'software.amazon.app.platform.lib'
}
appPlatformBuildSrc {
enableKotlinInject true
enablePublishing true
}
dependencies {
commonMainImplementation libs.metro.runtime
}
================================================
FILE: di-common/public/src/commonMain/kotlin/software/amazon/app/platform/inject/ContributesRenderer.kt
================================================
package software.amazon.app.platform.inject
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.reflect.KClass
import software.amazon.lastmile.kotlin.inject.anvil.extend.ContributingAnnotation
/**
* Used to contribute a renderer to our global registry of renderers that can be looked up by our
* runtime. E.g. given this renderer:
* ```
* @ContributesRenderer
* class IncrementRenderer : Renderer()
* ```
*
* This annotation would generated following component interface for kotlin-inject:
* ```
* @ContributesTo(RendererScope::class)
* interface IncrementRendererComponent {
* @Provides
* @IntoMap
* fun provideIncrementRendererIncrementPresenterModel(
* renderer: () -> IncrementRenderer,
* ): Pair, () -> Renderer<*>> = IncrementPresenter.Model::class to renderer
*
* @Provides
* fun provideIncrementRenderer(): IncrementRenderer = IncrementRenderer()
* }
* ```
*
* Or following graph for Metro:
* ```
* @ContributesTo(RendererScope::class)
* interface IncrementRendererGraph {
* @Provides
* @IntoMap
* @RendererKey(IncrementPresenter.Model::class)
* fun provideIncrementRendererIncrementPresenterModel(
* renderer: Provider,
* ): Renderer<*> = renderer()
*
* @Provides
* fun provideIncrementRenderer(): IncrementRenderer = IncrementRenderer()
* }
* ```
*
* Although strongly discouraged, your renderer is allowed to have an `@Inject constructor`. The
* only valid use case is for injecting other renderers returned by the `RendererFactory`.
*
* ```
* @Inject
* @ContributesRenderer
* class IncrementRenderer(
* private val rendererFactory: RendererFactory
* ) : Renderer() {
* ```
*
* If the model type is a sealed hierarchy, then for each explicit type a binding method will be
* generated.
*/
@Target(CLASS)
@ContributingAnnotation
public annotation class ContributesRenderer(
/**
* The class reference to the model class. Usually, it doesn't need to be specified and can be
* implied by the super type of the renderer.
*/
val modelType: KClass<*> = Unit::class,
/**
* If the `Model` class is a sealed hierarchy and this value is `true` (the default), then this
* renderer will be responsible for rendering all other sealed subtypes as well.
*/
val includeSealedSubtypes: Boolean = true,
)
================================================
FILE: di-common/public/src/commonMain/kotlin/software/amazon/app/platform/inject/robot/ContributesRobot.kt
================================================
package software.amazon.app.platform.inject.robot
import kotlin.reflect.KClass
import software.amazon.lastmile.kotlin.inject.anvil.extend.ContributingAnnotation
/**
* Robots must be annotated with `@ContributesRobot`. The annotation will generate the necessary
* code to provide the robot in the dependency graph and allow us to retrieve the robot through the
* `robot { }` function.
*
* ```
* @ContributesRobot(AppScope::class)
* class AbcRobot : Robot {
* ...
* }
* ```
*
* It's supported to inject dependencies in the constructor. For this the class must be annotated
* with `@Inject`:
* ```
* @Inject
* @ContributesRobot(AppScope::class)
* class AbcRobot(
* val someDependency: Dependency,
* ) : Robot() {
* ...
* }
* ```
*
* **ATTENTION:** Only `AppScope` is supported for now.
*/
@ContributingAnnotation
public annotation class ContributesRobot(
/** The scope in which to include this contributed binding. */
val scope: KClass<*>
)
================================================
FILE: di-common/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/PresenterCoroutineScope.kt
================================================
package software.amazon.app.platform.presenter
import dev.zacsweers.metro.Qualifier as MetroQualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
import kotlin.annotation.AnnotationTarget.PROPERTY
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
import kotlin.annotation.AnnotationTarget.TYPE
import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER
import me.tatarka.inject.annotations.Qualifier as KiQualifier
import software.amazon.app.platform.scope.coroutine.MainCoroutineDispatcher
/**
* A qualifier to identify the coroutine scope used to run presenters. This scope is commonly
* injected when converting a `Flow` to a `StateFlow`, see `stateInPresenter` for more details.
*
* This scope uses the [MainCoroutineDispatcher] by default, because presenters produce state for
* the UI and computing their models should have the highest priority.
*
* Never cancel this scope yourself, otherwise the application comes to a halt.
*/
@KiQualifier
@MetroQualifier
@Retention(RUNTIME)
@Target(CLASS, FUNCTION, PROPERTY_GETTER, VALUE_PARAMETER, TYPE, PROPERTY)
public annotation class PresenterCoroutineScope
================================================
FILE: di-common/public/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/DefaultCoroutineDispatcher.kt
================================================
package software.amazon.app.platform.scope.coroutine
import dev.zacsweers.metro.Qualifier as MetroQualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
import kotlin.annotation.AnnotationTarget.PROPERTY
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
import kotlin.annotation.AnnotationTarget.TYPE
import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER
import me.tatarka.inject.annotations.Qualifier as KiQualifier
/** Qualifier for the default dispatcher in the app scope. */
@KiQualifier
@MetroQualifier
@Retention(RUNTIME)
@Target(CLASS, FUNCTION, PROPERTY_GETTER, VALUE_PARAMETER, TYPE, PROPERTY)
public annotation class DefaultCoroutineDispatcher
================================================
FILE: di-common/public/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/IoCoroutineDispatcher.kt
================================================
package software.amazon.app.platform.scope.coroutine
import dev.zacsweers.metro.Qualifier as MetroQualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
import kotlin.annotation.AnnotationTarget.PROPERTY
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
import kotlin.annotation.AnnotationTarget.TYPE
import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER
import me.tatarka.inject.annotations.Qualifier as KiQualifier
/** Qualifier for the IO dispatcher in the app scope. */
@KiQualifier
@MetroQualifier
@Retention(RUNTIME)
@Target(CLASS, FUNCTION, PROPERTY_GETTER, VALUE_PARAMETER, TYPE, PROPERTY)
public annotation class IoCoroutineDispatcher
================================================
FILE: di-common/public/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/MainCoroutineDispatcher.kt
================================================
package software.amazon.app.platform.scope.coroutine
import dev.zacsweers.metro.Qualifier as MetroQualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
import kotlin.annotation.AnnotationTarget.PROPERTY
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
import kotlin.annotation.AnnotationTarget.TYPE
import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER
import me.tatarka.inject.annotations.Qualifier as KiQualifier
/** Qualifier for the main dispatcher in the app scope. */
@KiQualifier
@MetroQualifier
@Retention(RUNTIME)
@Target(CLASS, FUNCTION, PROPERTY_GETTER, VALUE_PARAMETER, TYPE, PROPERTY)
public annotation class MainCoroutineDispatcher
================================================
FILE: docs/di.md
================================================
# DI Framework
!!! note
App Platform provides support for [Metro](https://zacsweers.github.io/metro) and
[kotlin-inject-anvil](https://github.com/amzn/kotlin-inject-anvil) as dependency injection
frameworks. Metro is the recommended default, while `kotlin-inject-anvil` remains available as
the alternative and for existing codebases. Both frameworks are compile-time injection
frameworks and ready for Kotlin Multiplatform. They verify correctness of the object graph at
build time and avoid crashes at runtime.
Enabling dependency injection is an opt-in feature through the Gradle DSL. The default value is `false`.
```groovy
appPlatform {
enableMetro true
enableKotlinInject true
}
```
!!! tip
Start with the [Metro documentation](https://zacsweers.github.io/metro). Reach for the
[kotlin-inject-anvil documentation](https://github.com/amzn/kotlin-inject-anvil) when you are
maintaining the alternative path or migrating older code. App Platform makes heavy use of
`@ContributesBinding` and `@ContributesTo` annotations to decompose and assemble components /
object graphs.
## Metro
!!! note
Metro is an opt-in feature through the Gradle DSL. The default value is `false`.
```groovy
appPlatform {
enableMetro true
}
```
### Dependency graph
Dependency graphs are added as a service to the `Scope` class and can be obtained using the `metroDependencyGraph()`
extension function:
```kotlin
scope.metroDependencyGraph()
```
In modularized projects, final graphs are defined in the `:app` modules, because the object graph has to
know about all features of the app. It is strongly recommended to create an object graph in each platform specific
folder to provide platform specific types.
=== "Android"
```kotlin title="androidMain"
@DependencyGraph(AppScope::class)
interface AndroidAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(
@Provides application: Application,
@Provides rootScopeProvider: RootScopeProvider,
): AndroidAppGraph
}
}
```
=== "iOS"
```kotlin title="iosMain"
@DependencyGraph(AppScope::class)
interface IosAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(
@Provides uiApplication: UIApplication,
@Provides rootScopeProvider: RootScopeProvider,
): IosAppGraph
}
}
```
=== "Desktop"
```kotlin title="desktopMain"
@DependencyGraph(AppScope::class)
interface DesktopAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides rootScopeProvider: RootScopeProvider): DesktopAppGraph
}
}
```
=== "WasmJs"
```kotlin title="wasmJsMain"
@DependencyGraph(AppScope::class)
interface WasmJsAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides rootScopeProvider: RootScopeProvider): WasmJsAppGraph
}
}
```
### Platform implementations
Metro makes it simple to provide platform specific implementations for abstract APIs without needing
to use `expect / actual` declarations or any specific wiring. Since the final object graphs live in the platform
specific source folders, all contributions for a platform are automatically picked up. Platform specific
implementations can use and inject types from the platform.
```kotlin title="commonMain"
interface LocationProvider
```
=== "Android"
```kotlin title="androidMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidLocationProvider(
val application: Application,
) : LocationProvider
```
=== "iOS"
```kotlin title="iosMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class IosLocationProvider(
val uiApplication: UIApplication,
) : LocationProvider
```
=== "Desktop"
```kotlin title="desktopMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DesktopLocationProvider(
...
) : LocationProvider
```
=== "WasmJs"
```kotlin title="wasmJsMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class WasmLocationProvider(
...
) : LocationProvider
```
Other common code within `commonMain` can safely inject and use `LocationProvider`.
### Injecting dependencies
It's recommended to rely on constructor injection as much as possible, because it removes boilerplate and makes
testing easier. But it some cases it's required to get a dependency from an object graph where constructor injection
is not possible, e.g. in a static context or types created by the platform. In this case a contributed object graph
interface with access to the `Scope` help:
```kotlin title="androidMain"
class MainActivityViewModel(application: Application) : AndroidViewModel(application) {
private val graph = (application as RootScopeProvider).rootScope.metroDependencyGraph()
private val templateProvider = graph.templateProviderFactory.createTemplateProvider()
@ContributesTo(AppScope::class)
interface Graph {
val templateProviderFactory: TemplateProvider.Factory
}
}
```
This sample shows an Android `ViewModel` that doesn't use constructor injection. Instead, the `Scope` is retrieved
from the `Application` class and the Metro object graph is found through the `metroDependencyGraph()` function.
??? example "Sample"
The `ViewModel` example comes from the [sample app](https://github.com/amzn/app-platform/blob/main/sample/app/src/androidMain/kotlin/software/amazon/app/platform/sample/MainActivityViewModel.kt).
`ViewModels` can use constructor injection, but this requires more setup. This approach of using a graph
interface was simpler and faster.
Another example where this approach is handy is in [`NavigationPresenterImpl`](https://github.com/amzn/app-platform/blob/main/sample/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/navigation/NavigationPresenterImpl.kt).
This class waits for the user scope to be available and then optionally retrieves the `Presenter` that is part
of the user graph. Constructor injection cannot be used, because `NavigationPresenterImpl` is part of the app
scope and cannot inject dependencies from the user scope, which is a child scope of app scope. This would violate
dependency inversion rules.
```kotlin hl_lines="17"
@ContributesTo(UserScope::class)
interface UserGraph {
val userPresenter: UserPagePresenter
}
@Composable
override fun present(input: Unit): BaseModel {
val scope = getUserScope()
if (scope == null) {
// If no user is logged in, then show the logged in screen.
val presenter = remember { loginPresenter() }
return presenter.present(Unit)
}
// A user is logged in. Use the user graph to get an instance of UserPagePresenter, which is only
// part of the user scope.
val userPresenter = remember(scope) { scope.metroDependencyGraph().userPresenter }
return userPresenter.present(Unit)
}
```
### Default bindings
App Platform provides a few defaults that can be injected, including a `CoroutineScope` and `CoroutineDispatchers`.
```kotlin
@Inject
class SampleClass(
@ForScope(AppScope::class) appScope: CoroutineScope,
@IoCoroutineDispatcher ioDispatcher: CoroutineDispatcher,
@DefaultCoroutineDispatcher defaultDispatcher: CoroutineDispatcher,
@MainCoroutineDispatcher mainDispatcher: CoroutineDispatcher,
)
```
!!! info "CoroutineScope"
The `CoroutineScope` uses the IO dispatcher by default. The qualifier `@ForScope(AppScope::class)` is needed to
allow other scopes to have their own `CoroutineScope`. For example, the sample app provides a `CoroutineScope`
[for the user scope](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserComponent.kt),
which gets canceled when the user scope gets destroyed. The `CoroutineScope` for the user scope uses the qualifier
`@ForScope(UserScope::class)
```kotlin
/**
* Provides the [CoroutineScopeScoped] for the user scope. This is a single instance for the user
* scope.
*/
@Provides
@SingleIn(UserScope::class)
@ForScope(UserScope::class)
fun provideUserScopeCoroutineScopeScoped(
@IoCoroutineDispatcher dispatcher: CoroutineDispatcher
): CoroutineScopeScoped {
return CoroutineScopeScoped(dispatcher + SupervisorJob() + CoroutineName("UserScope"))
}
/**
* Provides the [CoroutineScope] for the user scope. A new child scope is created every time an
* instance is injected so that the parent cannot be canceled accidentally.
*/
@Provides
@ForScope(UserScope::class)
fun provideUserCoroutineScope(
@ForScope(UserScope::class) userScopeCoroutineScopeScoped: CoroutineScopeScoped
): CoroutineScope {
return userScopeCoroutineScopeScoped.createChild()
}
```
!!! info "CoroutineDispatcher"
It's recommended to inject `CoroutineDispatcher` through the constructor instead of using `Dispatcher.*`. This
allows to easily swap them within unit tests to remove concurrency and improve stability.
### `@ContributesScoped`
!!! warning
Metro uses `@ContributesScoped` for `Scoped` integrations. `kotlin-inject-anvil` achieves a
similar result by repurposing `@ContributesBinding` with a custom code generator.
The [`Scoped`](scope.md#scoped) interface is used to notify implementations when a `Scope` gets created and destroyed.
```kotlin
class AndroidLocationProvider : LocationProvider, Scoped {
override fun onEnterScope(scope: Scope) {
...
}
override fun onExitScope() {
...
}
}
```
The implementation class `AndroidLocationProvider` needs to be bound to the super type `LocationProvider` and use
multi-bindings for the `Scoped` interface. This is a lot of boilerplate to write that be auto-generated using
`@ContributesScoped` instead. When using `@ContributesScoped`, all bindings are generated and `@ContributesBinding`
doesn't need to be added. A typical implementation looks like this:
```kotlin hl_lines="3"
@Inject
@SingleIn(AppScope::class)
@ContributesScoped(AppScope::class)
class AndroidLocationProvider : LocationProvider, Scoped
```
See the documentation for [`Scoped`](scope.md#scoped) for more details.
### Missing integrations
Metro already supports almost all App Platform specific custom extensions that previously existed
for `kotlin-inject-anvil`, including `@ContributesRenderer` and `@ContributesRobot`. The remaining
gap is support for `@ContributesRealImpl` and `@ContributesMockImpl`, which still needs a Metro
equivalent.
### Migrating to Metro from kotlin-inject-anvil
Metro and `kotlin-inject-anvil` are conceptually very similar. Since Metro is the recommended
default, migrating existing `kotlin-inject-anvil` code is usually mostly mechanical. Errors will be
reported at compile time and not runtime.
Steps could like this. [PR/173](https://github.com/amzn/app-platform/pull/173) highlights this migration for the
`:sample` application.
* It's strongly recommended to use the latest Kotlin and Metro version. Metro is a compiler plugin and tied to the compiler to a certain degree.
* Enable Metro in the Gradle DSL:
```groovy
appPlatform {
enableMetro true
}
```
* Change kotlin-inject specific imports to Metro:
```
me.tatarka.inject.annotations.IntoSet -> dev.zacsweers.metro.IntoSet
me.tatarka.inject.annotations.Provides -> dev.zacsweers.metro.Provides
software.amazon.lastmile.kotlin.inject.anvil.AppScope -> dev.zacsweers.metro.AppScope
software.amazon.lastmile.kotlin.inject.anvil.ContributesTo -> dev.zacsweers.metro.ContributesTo
software.amazon.lastmile.kotlin.inject.anvil.ForScope -> dev.zacsweers.metro.ForScope
software.amazon.lastmile.kotlin.inject.anvil.SingleIn -> dev.zacsweers.metro.SingleIn
```
* Update the final kotlin-inject components to Metro. The Metro docs explain the API very well. E.g. this component had to adopt a factory:
```kotlin
// Old:
@Component
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class DesktopAppComponent(@get:Provides val rootScopeProvider: RootScopeProvider) :
DesktopAppComponentMerged
// New:
@DependencyGraph(AppScope::class)
interface DesktopAppComponent {
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides rootScopeProvider: RootScopeProvider): DesktopAppComponent
}
}
```
* Change usages of `addKotlinInjectComponent()` to `addMetroDependencyGraph()` and usages of `kotlinInjectComponent()` to `metroDependencyGraph()`.
## kotlin-inject-anvil
!!! note
This section documents the supported alternative path. Prefer the Metro section above for new
App Platform code.
`kotlin-inject-anvil` is an opt-in feature through the Gradle DSL. The default value is `false`.
```groovy
appPlatform {
enableKotlinInject true
}
```
### Component
Components are added as a service to the `Scope` class and can be obtained using the `kotlinInjectComponent()` extension
function:
```kotlin
scope.kotlinInjectComponent()
```
In modularized projects, final components are defined in the `:app` modules, because the object graph has to
know about all features of the app. It is strongly recommended to create a component in each platform specific
folder to provide platform specific types.
=== "Android"
```kotlin title="androidMain"
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class AndroidAppComponent(
@get:Provides val application: Application,
@get:Provides val rootScopeProvider: RootScopeProvider,
)
```
=== "iOS"
```kotlin title="iosMain"
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class IosAppComponent(
@get:Provides val uiApplication: UIApplication,
@get:Provides val rootScopeProvider: RootScopeProvider,
)
```
=== "Desktop"
```kotlin title="desktopMain"
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class DesktopAppComponent(
@get:Provides val rootScopeProvider: RootScopeProvider
)
```
=== "WasmJs"
```kotlin title="wasmJsMain"
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class WasmJsAppComponent(
@get:Provides val rootScopeProvider: RootScopeProvider
)
```
### Platform implementations
`kotlin-inject-anvil` makes it simple to provide platform specific implementations for abstract APIs without needing
to use `expect / actual` declarations or any specific wiring. Since the final components live in the platform specific
source folders, all contributions for a platform are automatically picked up. Platform specific implementations can
use and inject types from the platform.
```kotlin title="commonMain"
interface LocationProvider
```
=== "Android"
```kotlin title="androidMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidLocationProvider(
val application: Application,
) : LocationProvider
```
=== "iOS"
```kotlin title="iosMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class IosLocationProvider(
val uiApplication: UIApplication,
) : LocationProvider
```
=== "Desktop"
```kotlin title="desktopMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DesktopLocationProvider(
...
) : LocationProvider
```
=== "WasmJs"
```kotlin title="wasmJsMain"
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class WasmLocationProvider(
...
) : LocationProvider
```
Other common code within `commonMain` can safely inject and use `LocationProvider`.
### Injecting dependencies
It's recommended to rely on constructor injection as much as possible, because it removes boilerplate and makes
testing easier. But it some cases it's required to get a dependency from a component where constructor injection
is not possible, e.g. in a static context or types created by the platform. In this case a contributed component
interface with access to the `Scope` help:
```kotlin title="androidMain"
class MainActivityViewModel(application: Application) : AndroidViewModel(application) {
private val component = (application as RootScopeProvider).rootScope.kotlinInjectComponent()
private val templateProvider = component.templateProviderFactory.createTemplateProvider()
@ContributesTo(AppScope::class)
interface Component {
val templateProviderFactory: TemplateProvider.Factory
}
}
```
This sample shows an Android `ViewModel` that doesn't use constructor injection. Instead, the `Scope` is retrieved
from the `Application` class and the `kotlin-inject-anvil` component is found through the `kotlinInjectComponent()` function.
??? example "Sample"
The `ViewModel` example comes from the [sample app](https://github.com/amzn/app-platform/blob/main/sample/app/src/androidMain/kotlin/software/amazon/app/platform/sample/MainActivityViewModel.kt).
`ViewModels` can use constructor injection, but this requires more setup. This approach of using a component
interface was simpler and faster.
Another example where this approach is handy is in [`NavigationPresenterImpl`](https://github.com/amzn/app-platform/blob/main/sample/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/navigation/NavigationPresenterImpl.kt).
This class waits for the user scope to be available and then optionally retrieves the `Presenter` that is part
of the user component. Constructor injection cannot be used, because `NavigationPresenterImpl` is part of the app
scope and cannot inject dependencies from the user scope, which is a child scope of app scope. This would violate
dependency inversion rules.
```kotlin hl_lines="17"
@ContributesTo(UserScope::class)
interface UserComponent {
val userPresenter: UserPagePresenter
}
@Composable
override fun present(input: Unit): BaseModel {
val scope = getUserScope()
if (scope == null) {
// If no user is logged in, then show the logged in screen.
val presenter = remember { loginPresenter() }
return presenter.present(Unit)
}
// A user is logged in. Use the user component to get an instance of UserPagePresenter, which is only
// part of the user scope.
val userPresenter = remember(scope) { scope.kotlinInjectComponent().userPresenter }
return userPresenter.present(Unit)
}
```
### Default bindings
App Platform provides a few defaults that can be injected, including a `CoroutineScope` and `CoroutineDispatchers`.
```kotlin
@Inject
class SampleClass(
@ForScope(AppScope::class) appScope: CoroutineScope,
@IoCoroutineDispatcher ioDispatcher: CoroutineDispatcher,
@DefaultCoroutineDispatcher defaultDispatcher: CoroutineDispatcher,
@MainCoroutineDispatcher mainDispatcher: CoroutineDispatcher,
)
```
!!! info "CoroutineScope"
The `CoroutineScope` uses the IO dispatcher by default. The qualifier `@ForScope(AppScope::class)` is needed to
allow other scopes to have their own `CoroutineScope`. For example, the sample app provides a `CoroutineScope`
[for the user scope](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserComponent.kt),
which gets canceled when the user scope gets destroyed. The `CoroutineScope` for the user scope uses the qualifier
`@ForScope(UserScope::class)
```kotlin
/**
* Provides the [CoroutineScopeScoped] for the user scope. This is a single instance for the user
* scope.
*/
@Provides
@SingleIn(UserScope::class)
@ForScope(UserScope::class)
fun provideUserScopeCoroutineScopeScoped(
@IoCoroutineDispatcher dispatcher: CoroutineDispatcher
): CoroutineScopeScoped {
return CoroutineScopeScoped(dispatcher + SupervisorJob() + CoroutineName("UserScope"))
}
/**
* Provides the [CoroutineScope] for the user scope. A new child scope is created every time an
* instance is injected so that the parent cannot be canceled accidentally.
*/
@Provides
@ForScope(UserScope::class)
fun provideUserCoroutineScope(
@ForScope(UserScope::class) userScopeCoroutineScopeScoped: CoroutineScopeScoped
): CoroutineScope {
return userScopeCoroutineScopeScoped.createChild()
}
```
!!! info "CoroutineDispatcher"
It's recommended to inject `CoroutineDispatcher` through the constructor instead of using `Dispatcher.*`. This
allows to easily swap them within unit tests to remove concurrency and improve stability.
## Metro vs `kotlin-inject-anvil`
Metro supports all features of `kotlin-inject-anvil` and `kotlin-inject`, produces more efficient code, provides
better error messages and compiles much faster. Metro is the recommended default for new projects,
while `kotlin-inject-anvil` remains the supported alternative when you need compatibility with
existing code. We strongly recommend using Metro for new projects and migrating existing projects
soon.
================================================
FILE: docs/faq.md
================================================
# FAQ
#### How can I incrementally adopt App Platform?
App Platform offers many recommendations and best practices and hardly enforces any principles, e.g.
it’s possible to adopt the concept of the module structure without the `Scope` class or `Presenters`.
`Presenters` can be used without Compose UI. This and the fact that App Platform is extensible allows
for an incremental adoption. Apps can leverage the concepts and the framework without migrating all code at
once.
For example, instead of going all in on the unidirectional dataflow, Android apps can start adopting `Presenters` and
`Renderers` on an Activity by Activity or Fragment by Fragment basis. Today we recommend starting
new App Platform code with Metro. Earlier, our Android app initially used
[Dagger 2](https://dagger.dev/) and [Anvil](https://github.com/square/anvil) as dependency
injection framework and later made it interop with `kotlin-inject-anvil` before switching fully.
#### Can I use [Dagger 2](https://dagger.dev/) or any other DI framework?
It depends, but likely yes. App Platform recommends [Metro](di.md) as the default DI framework because
it supports Kotlin Multiplatform, verifies the dependency graph at compile time, and is the direction
the framework docs and examples assume.
[kotlin-inject-anvil](https://github.com/amzn/kotlin-inject-anvil) remains supported as the
alternative, especially for existing codebases or when you need compatibility with older App Platform
examples.
Dagger 2 is more challenging, because it only supports Android and JVM application. Metro is the
recommended default today, though App Platform started on Android with Dagger 2 and we first
bridged those Dagger 2 components with `kotlin-inject-anvil` for interop.
#### How does App Platform compare to [Circuit](https://slackhq.github.io/circuit/)?
Circuit shares certain aspects with App Platform in regards to implementing the unidirectional dataflow,
e.g. presenters and decoupling UI. How `Screens` with Circuit work vs how App Platform relies on composing presenters
and renderers is different.
App Platform goes further and has feature that Circuit doesn't provide, e.g. the module structure, the strong
emphasis on fakes and robots.
At Amazon we built App Platform months before Circuit was released in 2022 and at this point there was no reason for
us to migrate off of App Platform and to Circuit.
!!! note "Help needed"
Help from the community for a more in-depth comparison is needed.
#### Is App Platform used in production by Amazon?
App Platform was developed within the Amazon Delivery organization and is used to share code between several
applications and platforms. Public products include the [in-vehicle delivery app](https://www.youtube.com/watch?v=0T_zvUEqsD4),
[Amazon Flex for Android and iOS](https://flex.amazon.com/) and the Linux based
[Vision-Assisted Package Retrieval](https://www.aboutamazon.com/news/transportation/amazon-vapr-delivery-van-packages).
================================================
FILE: docs/index.md
================================================
---
social:
cards_layout_options:
title: Application framework for KMP
---
# App Platform
## Introduction
{ align=left width="150" }
The App Platform is a lightweight application framework for state and memory management suitable
for Kotlin Multiplatform projects, in particular Android, iOS, JVM, native and Web. It makes the
dependency inversion (1) and dependency injection (DI) design patterns first class principles to develop
features and support the variety of platforms. The UI layer is entirely decoupled from the business logic,
which allows different application targets to change the look and feel.
{ .annotate }
1. Dependency inversion means that high-level APIs don’t depend on low-level details and low-level details only import other high-level APIs.
App Platform pushes for code reuse by sharing APIs and implementations, while making it easy to leverage
platform strengths and changing app or device specific behavior when needed. The framework helps you to get started
writing Kotlin Multiplatform effectively.
=== "Web (clickable)"
=== "Android"
{ width="300" }
=== "iOS"
{ width="300" }
=== "Desktop"
{ width="300" }
=== "Web Recipe App"
## Overview
App Platform combines several features as a single framework. While all of them are optional, together they help
to implement recommended best practices and design patterns.
### Module Structure
The [module structure](module-structure.md) helps to separate APIs from implementations. This prevents leaking
implementation details, forces developers to think about strong APIs and reduces build times. Checks for the correct
usage of the module structure are implemented in the Gradle plugin.
### Dependency Injection
App Platform provides first-class support for [Metro](di.md#metro) and
[kotlin-inject-anvil](di.md#kotlin-inject-anvil) as dependency injection solutions. Metro is the
recommended default, but these frameworks aren't enforced and you can bring your own (1).
{ .annotate }
1. Today App Platform recommends [Metro](https://zacsweers.github.io/metro) for new work.
Historically, the very first versions at Amazon used [Dagger 2](https://dagger.dev/) and
[Anvil](https://github.com/square/anvil), and later migrated to
[kotlin-inject-anvil](https://github.com/amzn/kotlin-inject-anvil).
### Scopes
[`Scopes`](scope.md) are essential in our architecture. They define the boundary our software components
operate in. A scope is a space with a well-defined lifecycle that can be created and torn down. App Platform
provides hooks to create your own scopes with easy callbacks, integration for dependency injection
frameworks and `CoroutineScopes`.
### Presenters
[Presenters](presenter.md) are implemented using [Molecule](https://github.com/cashapp/molecule). Writing business and
navigation logic using *Compose* is significantly easier than chaining `Flows`.
### UI
The UI layer is fully decoupled using [Renderers](renderer.md). [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
is fully supported out of the box. For Android there is seamless interop with Android `Views` (1).
{ .annotate }
1. We have a mix of both UI frameworks on Android.
### Testing
Fakes for unit and device tests are essential and integral part of our architecture. There are many
[test helpers](testing.md) to setup fakes for core components such as `Scopes`. We like using
[Turbine](https://github.com/cashapp/turbine/) for verifying the reactive behavior of our `Presenters`.
Thanks to *Compose Multiplatform*, `Renderers` [can be tested](renderer.md#unit-tests) in isolation for iOS
and Desktop.
### Integration
The [Gradle plugin](setup.md) comes with a convenient DSL to take care of many necessary configurations, e.g. it sets
up the *Compose* compiler for *Molecule* and *Compose Multiplatform*. It configures KSP and integrates
*Metro* or *kotlin-inject-anvil* for each platform. It sets the Android namespace and artifact ID when the module
structure is enabled.
## Getting Started
App Platform gives you a working Kotlin Multiplatform setup out of the box, with support for Android, iOS, Desktop, and Web (WASM). The fastest way to get started is by using the [blueprints/starter](https://github.com/amzn/app-platform/tree/main/blueprints/starter) project — a fully functional example app that already uses App Platform and applies everything the platform provides, including the module structure, dependency injection, scopes, presenters, and renderers.
### Copy the Starter App
To begin a new project:
```bash
git clone https://github.com/amzn/app-platform.git
cp -r app-platform/blueprints/starter my-kmp-app
cd my-kmp-app
```
The starter blueprint comes preconfigured with App Platform and is ready to build and run across all supported targets.
### Build and Run
The starter project includes a detailed [README](https://github.com/amzn/app-platform/blob/main/blueprints/starter/README.md) with instructions for building and running the app on each platform:
- Android
- iOS
- Desktop
- Web (WASM)
Follow the steps in that README to get your app running locally.
## License
```
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
================================================
FILE: docs/module-structure.md
================================================
# Module Structure
!!! note
Using the module structure is an opt-in feature through the Gradle DSL. The default value is `false` and
this feature has to be enabled for each module.
```groovy
appPlatform {
enableModuleStructure true
}
```
!!! tip
[`:impl`](module-structure.md#impl) modules are usually imported by the final [`:app`](module-structure.md#app)
modules. This also applies to App Platform itself. This Gradle option imports all necessary `:impl` modules for
enabled features.
```groovy
appPlatform {
addImplModuleDependencies true
}
```
!!! example "Sample"
App Platform itself and the [sample app](https://github.com/amzn/app-platform/tree/main/sample) use the module
structure to separate APIs from implementations. The sample app highlights how we structure code and make use
of the various module types.
## Dependency inversion
Dependency inversion means that high-level APIs don’t depend on low-level details and low-level details
only import other high-level APIs. It significantly reduces coupling between components. Dependency
inversion can be implemented on different levels, e.g. in code and in the module structure.
### Kotlin code
Dependency inversion implemented in Kotlin code refers to having abstractions in place instead of
relying on concrete implementations. Imagine this example:
```kotlin
class AccountProvider(
private val database: SqliteDatabase,
...
) {
val currentAccount: StateFlow = ...
fun updateCurrentAccount(account: Account) {
...
}
}
class ChangeAccountHandler(
private val accountProvider: AccountProvider
) {
private fun onAccountChanged(account: Account) {
accountProvider.updateCurrentAccount(account)
...
}
}
```
`ChangeAccountHandler` has a strong dependency on `AccountProvider`. This is problematic in multiple ways.
Evolving `AccountProvider` is challenging, because implementation details are easily leaked and become
part of the public API. Every dependency from `AccountProvider` is exposed to consumers, e.g. `ChangeAccountHandler`
knows that `AccountProvider` uses Sqlite for its implementation, a detail which should be hidden and makes
dependency graphs unnecessarily large. `ChangeAccountHandler` is hard to test. One has to spin up a Sqlite database
in a unit test environment in order to instantiate `AccountProvider` and pass it as argument to
`ChangeAccountHandler`.
A much better approach is introducing abstract APIs:
```kotlin
interface AccountProvider {
val currentAccount: StateFlow
fun updateCurrentAccount(account: Account)
}
class SqliteAccountProvider(
private val database: SqliteDatabase
...
) : AccountProvider {
@VisibleForTesting
val allAccounts: List = ...
...
}
```
The interface `AccountProvider` solves the mentioned shortcomings. `SqliteAccountProvider` can change and
for example expose more fields (`allAccounts` in this sample) for verifications in unit tests without anyone
knowing as the interface doesn’t need to be updated. Sqlite is a pure implementation detail and no consumer
of `AccountProvider` has to know about it. This allows us to easily swap the implementation for a fake
`AccountProvider` together with fake data in a unit test for `ChangeAccountHandler`.
Breaking the dependency serves an additional purpose especially in Kotlin Multiplatform when
implementations have platform dependencies:
```kotlin
// commonMain
interface SqlDriver
// androidMain
class AndroidSqlDriver(context: Context) : SqlDriver
// iosMain
class NativeSqlDriver() : SqlDriver
```
Notice how the Android implementation has a strong dependency on the Android runtime through the `Context`
class. Relying on interfaces / abstract classes together with dependency injection is the
[preferred way](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-connect-to-apis.html#dependency-injection-framework) (1)
over `expect / actual` functions to implement dependency inversion as this approach allows platform specific changes.
{ .annotate }
1. When you use a DI framework, you inject all of the dependencies through this framework. The same logic applies to handling platform dependencies. We recommend continuing to use DI if you already have it in your project, rather than using the expected and actual functions manually. This way, you can avoid mixing two different ways of injecting dependencies.
### Gradle modules
The App Platform separates APIs from implementations by splitting the code in separate Gradle modules. The same
recommendation applies not only to other core libraries but also feature code due to the many benefits such as
smaller dependency graphs, lower coupling and a simple mechanism to replace dependencies with fakes.
Imagine having two implementations of the shared interface `LocationProvider` for two applications
*Delivery App* and *Navigation App*:
```kotlin
interface LocationProvider {
val location: StateFlow
}
class DeliveryAppLocationProvider(
private val dataLayer: DeliveryAppDataLayer,
...
) : LocationProvider {..}
class NavigationAppLocationProvider(
private val application: NavigationApplication,
...
) : LocationProvider {..}
```
If both classes live in the same module, then the shared Gradle module must depend on modules belonging to
*Delivery* and *Navigation* App at the same time. This is not ideal, because then the *Delivery App* would
automatically depend on code from the *Navigation App* and the *Navigation App* on *Delivery App* code through
a transitive dependency as highlighted in the diagram below.
```mermaid
%%{init: {'themeCSS': '.label { font-family: monospace; }'}}%%
graph TD
delivery-platform["`:delivery-platform`"]
navigation-platform["`:navigation-platform`"]
location["`**:location**
*DeliveryAppLocationProvider*
*NavigationAppLocationProvider*`"]
delivery-app["`:delivery-app`"]
navigation-app["`:navigation-app`"]
delivery-platform --> location
navigation-platform --> location
location --> delivery-app
location --> navigation-app
```
To avoid the issue of the transitive dependencies, concrete implementation classes `DeliveryAppLocationProvider`
and `NavigationAppLocationProvider` could be moved into the final respective application packages `:delivery-app`
and `:navigation-app`.
```mermaid
%%{init: {'themeCSS': '.label { font-family: monospace; }'}}%%
graph TD
delivery-platform["`:delivery-platform`"]
location["`:location`"]
navigation-platform["`:navigation-platform`"]
delivery-app["`**:delivery-app**
*DeliveryAppLocationProvider*`"]
navigation-app["`**:navigation-app**
*NavigationAppLocationProvider*`"]
delivery-platform --> delivery-app
navigation-platform --> navigation-app
location --> delivery-app
location --> navigation-app
```
However, this would be a bad approach from a modularization standpoint. The app modules would become
larger and larger over time and the many classes within it would have a low cohesion level. Build times get
longer roughly linear to the size of the module, because individual build steps such as Kotlin compilation
can’t be parallelized.
Instead, a similar approach to [dependency inversion in Kotlin code](module-structure.md#kotlin-code)
is applied to modules. The shared package can be split into a public API and implementation sub-module:
```mermaid
%%{init: {'themeCSS': '.label { font-family: monospace; }'}}%%
graph TD
delivery-platform["`:delivery-platform`"]
location-public["`:location:public`"]
navigation-platform["`:navigation-platform`"]
location-impl-delivery["`**:location:impl-delivery**
*DeliveryAppLocationProvider*`"]
location-impl-navigation["`**:location:impl-navigation**
*NavigationAppLocationProvider*`"]
delivery-app["`:delivery-app`"]
navigation-app["`:navigation-app`"]
delivery-platform --> location-impl-delivery
navigation-platform --> location-impl-navigation
location-public --> location-impl-delivery
location-public --> location-impl-navigation
location-impl-delivery --> delivery-app
location-impl-navigation --> navigation-app
```
By cleanly separating shared code in `:public` modules from implementations in `:impl` modules we break
dependencies in our build graph. `DeliveryAppLocationProvider` and `NavigationAppLocationProvider` provide a
separate implementation for each application target of the shared API, have dependencies on each individual
platform and yet don’t leak any implementation details nor platform APIs.
## Module rules
In order to follow the dependency inversion principle correctly the most important rule in this module structure
is that no other module but the final application module is allowed to depend on `:impl` modules. `:public`
modules on the other hand are widely shared and can be imported by any other module.
{ width="600" }
A library always comes with a single `:public` module for shared code. There can be zero, one or more `:impl`
modules, e.g. when dependency inversion isn’t needed, then the `:impl` module is redundant. When the implementation
can be shared between all apps, then only a single `:impl` module is needed. When there are multiple different
implementations for different applications, then multiple `:impl` modules are required like in the example above.
To make code easier to discover, it’s recommended to put all Gradle modules into the same sub module.
This module structure reduces coupling between libraries and increases cohesion within modules, which are two
desired attributes in a modularized codebase. `:impl` modules can change and be modified without impacting any
other library. Our build dependency graph stays flat and all `:impl` modules can be compiled and assembled in
parallel.
The `:public / :impl` module split is recommended whenever dependency inversion is needed for code, because of
all the benefits mentioned above. The split becomes more natural over time and the benefit increases. Rare
exceptions are when dependency inversion isn’t applied such as for sharing utilities like extension functions,
UI components or test helpers.
## Module types
Beyond `:public` and `:impl` modules, there are further optional module types:
{ width="600" }
### `:public`
`:public` modules contain the code that should be shared and reused by other modules and libraries.
APIs (interfaces) usually live in `:public` modules, but also code where dependency inversion isn’t applied
such as static utilities, extension functions and UI components.
### `:impl`
`:impl` modules contain the concrete implementations of the API from `:public` modules. A library can have
zero or more `:impl` modules. If a library contains multiple `:impl` modules, then they’re suffixed with a name,
e.g. `:login:impl-amazon` and `:login:impl-google`.
### `:internal`
`:internal` modules are used when code should be shared between multiple `:impl` modules of the same library,
but the code should not be exposed through the `:public` module. This code is *internal* to this library.
### `:testing`
`:testing` modules provide a mechanism to share utilities or fake implementations for tests with other libraries.
`:testing` modules are allowed to be imported as test dependency by any other module type and are never added
to the runtime classpath. Even its own `:public` module can reuse the code from the `:testing` module for its tests.
### `:robots`
`:*-robots` modules help implementing the robot pattern for UI tests and make them shareable. Robots must know
about concrete implementations, therefore they usually depend on an `:impl` module, but don't expose this `:impl`
module on the compile classpath. `:robot` modules are only imported and reused for UI tests and are never
added as dependency to the runtime classpath of a module similar to `:testing` modules.
### `:app`
`:app` modules refer to the final application, where all feature implementations are imported and assembled
as a single binary. Therefore, `:app` modules are allowed to depend on `:impl` modules of all imported libraries
and features.
## Example
A more complex dependency graph could look like this:

This example highlights many of the more frequently used dependencies. Notice that the impl modules
`:location:impl-delivery` and `:location:impl-navigation` both depend on the internal module `:location:internal`
to share some implementations, but non-shared code lives in each `:impl` module. The `:impl` modules import
application specific code `:delivery-app-platform:public` and `:navigation-app-platform:public` safely without
leaking the code to the wrong app. Further, `:location:impl-navigation` imports and uses `:navigation:public`,
but neither the other impl module `:location:impl-delivery` nor its public module `:location:public` need to
know about this dependency or depend on it.
The second library `:navigation:public`, which imports `:location:public`, reuses testing module `:location:testing`
for its unit tests. This saves boilerplate to setup fake implementations of the shared APIs from `:location:public`
and discourages using mocking frameworks.
The app `:navigation-app` imports its specific impl module `:location:impl-navigation`. It also reuses the
robots from the `:location:impl-navigation-robots` module for its UI tests, further reducing strong dependencies
on concrete implementations and favoring reusability.
## Gradle setup
Using the module structure is an opt-in feature through the Gradle DSL. The default value is `false` and
this feature has to be enabled for each module.
```groovy
appPlatform {
enableModuleStructure true
}
```
With this setting enabled, several checks and features are enabled:
* App Platform ensures that the Gradle module follows the naming convention, e.g. it's named `:public` or `:impl`.
* Default dependencies are added, e.g. an `:impl` module imports its `:public` module by default, or `:impl-robots` imports its `:impl` module by default.
* An [Android namespace](https://developer.android.com/build/configure-app-module#set-namespace) is set [automatically](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/ModuleStructurePlugin.kt#L90-L110) if it hasn't been configured yet.
* A Gradle task `:checkModuleStructureDependencies` is registered, which verifies that module structure dependency rules are followed. The `:check` Gradle task automatically depends on `:checkModuleStructureDependencies`.
* A consistent API for an [`Project.artifactId`](https://github.com/amzn/app-platform/blob/main/gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/ModuleStructurePlugin.kt#L125-L135) is available, e.g. for `:my-module:public` it would return `my-module-public`.
??? example "Sample"
The sample application doesn't set the Android namespace anywhere. Instead, it relies on the default from
App Platform, e.g. the `:sample:templates:impl` module uses this generated namespace for its `R` class:
```kotlin
software.amazon.app.platform.sample.templates.impl.R
```
App Platform uses the `Project.artifactId()` API for its own modules. Publishing using the
[Gradle Maven Publish Plugin](https://vanniktech.github.io/gradle-maven-publish-plugin/) is configured
[here](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/buildSrc/src/main/kotlin/software/amazon/app/platform/gradle/buildsrc/SdkPlugin.kt#L16-L34).
```kotlin
private fun mavenPublishing(project: Project) {
plugins.apply(Plugins.MAVEN_PUBLISH)
project.extensions
.getByType(MavenPublishBaseExtension::class.java)
.coordinates(artifactId = project.artifactId())
}
```
================================================
FILE: docs/presenter.md
================================================
# Presenter
!!! note
While App Platform has a generic `Presenter` interface to remove coupling, we strongly recommend using
`MoleculePresenter` for implementations. `MoleculePresenters` are an opt-in feature through the Gradle DSL.
The default value is `false`.
```groovy
appPlatform {
enableMoleculePresenters true
}
```
## Unidirectional dataflow
App Platform implements the unidirectional dataflow pattern to decouple business logic from UI rendering. Not only
does this allow for better testing of business logic and provides clear boundaries, but individual apps can also
share more code and change the look and feel when needed.
## `MoleculePresenter`
In the unidirectional dataflow pattern events and state only travel into one direction through a single stream.
State is produced by `Presenters` and can be observed through a reactive stream:
```kotlin
interface Presenter {
val model: StateFlow
}
```
`Presenters` can be implemented in many ways as long as they can be converted to this interface. App Platform
provides and recommends the implementation using [Molecule](https://github.com/cashapp/molecule) since it provides
many advantages. Molecule is a library that turns a `@Composable` function into a `StateFlow`. It leverages the
core of [Compose](https://developer.android.com/compose) without bringing in Compose UI as dependency. The primary
use case of Compose is handling, creating and modifying tree-like data structures, which is a natural fit for
UI frameworks. Molecule reuses Compose to handle state management and state transitions to implement business
logic in the form of `@Composable` functions with all the benefits that Compose provides.
The [MoleculePresenter](https://github.com/amzn/app-platform/blob/main/presenter-molecule/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/molecule/MoleculePresenter.kt)
interface looks like this:
```kotlin
interface MoleculePresenter {
@Composable
fun present(input: InputT): ModelT
}
```
[`Models`](https://github.com/amzn/app-platform/blob/main/presenter/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/BaseModel.kt)
represent the state of a `Presenter`. Usually, they’re implemented as immutable, inner data classes of the `Presenter`.
Using sealed hierarchies is a good practice to allow to differentiate between different states:
```kotlin
interface LoginPresenter : MoleculePresenter {
sealed interface Model : BaseModel {
data object LoggedOut : Model
data class LoggedIn(
val user: User,
) : Model
}
}
```
Notice that it’s recommended even for `Presenters` to follow the dependency inversion principle. `LoginPresenter` is
an interface and there can be multiple implementations.
??? example "Sample"
The sample application follows the same principle of dependency inversion. E.g. the API of the
[`LoginPresenter`](https://github.com/amzn/app-platform/blob/main/sample/login/public/src/commonMain/kotlin/software/amazon/app/platform/sample/login/LoginPresenter.kt)
is part of the `:public` module, while the implementation [`LoginPresenterImpl`](https://github.com/amzn/app-platform/blob/main/sample/login/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/login/LoginPresenterImpl.kt)
lives in the `:impl` module. This abstraction is used in tests, where [`FakeLoginPresenter`](https://github.com/amzn/app-platform/blob/main/sample/navigation/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/navigation/NavigationPresenterImplTest.kt#L45-L49)
simplifies the test setup of classes relying on `LoginPresenter`.
Observers of the state of a `Presenter`, such as the UI layer, communicate back to the `Presenter` through events.
Events are sent through a lambda in the `Model`, which the `Presenter` must provide:
```kotlin hl_lines="16"
interface LoginPresenter : MoleculePresenter {
sealed interface Event {
data object Logout : Event
data class ChangeName(
val newName: String,
) : Event
}
sealed interface Model : BaseModel {
data object LoggedOut : Model
data class LoggedIn(
val user: User,
val onEvent: (Event) -> Unit,
) : Model
}
}
```
A concrete implementation of `LoginPresenter` could look like this:
```kotlin
@Inject
@ContributesBinding(AppScope::class)
class AmazonLoginPresenter : LoginPresenter {
@Composable
fun present(input: Unit): Model {
..
return if (user != null) {
LoggedIn(user = user) { event ->
when (event) {
is Logout -> ..
is ChangeName -> ..
}
}
} else {
LoggedOut
}
}
}
```
!!! note
`MoleculePresenters` are never singletons. While they use Metro or `kotlin-inject-anvil` for constructor injection and
automatically bind the concrete implementation to an API using `@ContributesBinding`, they don't use the
`@SingleIn` annotation. `MoleculePresenters` manage their state in the `@Composable` function with the Compose
runtime. Therefore, it's strongly discouraged to have any class properties.
## Model driven navigation
`Presenters` are composable, meaning that one presenter could combine N other presenters into a single stream of
model objects. With that concept in mind we can decompose large presenters into multiple smaller ones. Not only
do they become easier to change, maintain and test, but we can also share and reuse presenters between multiple
screens if needed. Presenters form a tree with nested presenters. They’re unaware of their parent and communicate
upwards only through their `Model`.
```kotlin hl_lines="14 17"
class OnboardingPresenterImpl(
// Make presenters lazy to only instantiate them when they're actually needed.
private val lazyLoginPresenter: () -> LoginPresenter,
private val lazyRegistrationPresenter: () -> RegistrationPresenter,
) : OnboardingPresenter {
@Composable
fun present(input: Unit): BaseModel {
...
return if (mustRegister) {
// Remember the presenter to avoid creating a new one during each
// composition (in other words when computing a new model).
val registrationPresenter = remember { lazyRegistrationPresenter() }
registrationPresenter.present(Unit)
} else {
val loginPresenter = remember { lazyLoginPresenter() }
loginPresenter.present(Unit)
}
}
}
```
Notice how the parent presenter calls the `@Composable` `present()` function from the child presenters like
a regular function to compute their model and return it.
??? example "Sample"
[`NavigationPresenterImpl`](https://github.com/amzn/app-platform/blob/main/sample/navigation/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/navigation/NavigationPresenterImpl.kt)
is another example that highlights this principle.
[`UserPagePresenterImpl`](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPagePresenterImpl.kt)
goes a step further. Its `BaseModel` is composed of two sub-models. The `listModel` is even an input for the
detail-presenter.
```kotlin
val listModel = userPageListPresenter.present(UserPageListPresenter.Input(user))
return Model(
listModel = listModel,
detailModel =
userPageDetailPresenter.present(
UserPageDetailPresenter.Input(user, selectedAttribute = listModel.selectedIndex)
),
)
```
This concept allows us to implement model-driven navigation. By driving the entire UI layer through `Presenters` and
emitted `Models` navigation becomes a first class API and testable. Imagine having a root presenter implementing a
back stack that forwards the model of the top most presenter. When the user navigates to a new screen, then the
root presenter would add a new presenter to the stack and provide its model object.
```mermaid
%%{init: {'themeCSS': '.label { font-family: monospace; }'}}%%
graph TD
login["`Login presenter`"]
registration["`Register presenter`"]
onboarding["`Onboarding presenter`"]
delivery["`Delivery presenter`"]
settings["`Settings presenter`"]
root["`Root presenter`"]
ui["`UI Layer`"]
login --> onboarding
registration --> onboarding
onboarding --> root
delivery --> root
settings --> root
root --> ui
style ui stroke:#0f0
```
In the example above, the root presenter would forward the model of the onboarding, delivery or settings presenter
to the UI layer. The onboarding presenter as shown in the code example can either call the login or registration
presenter based on a condition. With Molecule calling a child presenter is as easy as invoking a function.
## Parent child communication
While the pattern isn’t used frequently, parent presenters can provide input to their child presenters. The
returned model from the child presenter can be used further to change the control flow.
```kotlin
interface ChildPresenter : MoleculePresenter {
data class Input(
val argument: String,
)
}
class ParentPresenterImpl(
private val lazyChildPresenter: () -> ChildPresenter
) : ParentPresenter {
@Composable
fun present(input: Unit) {
val childPresenter = remember { lazyChildPresenter() }
val childModel = childPresenter.present(Input(argument = "abc"))
return if (childModel...) ...
}
}
```
This mechanism is favored less, because it only allows for direct parent to child presenter interactions and
becomes hard to manage for deeply nested hierarchies. More often a service object is injected instead, which
is used by the multiple presenters:
```kotlin hl_lines="8 12 20 25 28"
interface AccountManager {
val currentAccount: StateFlow
fun mustRegister(): Boolean
}
class AmazonLoginPresenter(
private val accountManager: AccountManager
): LoginPresenter {
@Composable
fun present(input: Unit): Model {
val account by accountManager.currentAccount.collectAsState()
...
}
}
class OnboardingPresenterImpl(
private val lazyLoginPresenter: () -> LoginPresenter,
private val lazyRegistrationPresenter: () -> RegistrationPresenter,
private val accountManager: AccountManager,
) : OnboardingPresenter {
@Composable
fun present(input: Unit): BaseModel {
val account by accountManager.currentAccount.collectAsState()
...
return if (accountManager.mustRegister()) {
val registrationPresenter = remember { lazyRegistrationPresenter() }
registrationPresenter.present(Unit)
} else {
val loginPresenter = remember { lazyLoginPresenter() }
loginPresenter.present(Unit)
}
}
}
```
This example shows how `AccountManager` holds state and is injected into multiple presenters instead of relying
on presenter inputs.
## Launching
`MoleculePresenters` can inject other presenters and call their `present()` function inline. If you are already in a
composable UI context, then you can simply call the presenter to compute the model:
```kotlin
fun mainViewController(): UIViewController = ComposeUIViewController {
val presenter = remember { LoginPresenter() }
val model = presenter.present(Unit)
...
}
```
In this example the `LoginPresenter` model is computed from an iOS Compose Multiplatform function.
In other scenarios a composable context may not be available and it's necessary to turn the `@Composable` functions
into a `StateFlow` for consumption.
[`MoleculeScope`](https://github.com/amzn/app-platform/blob/main/presenter-molecule/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/molecule/MoleculeScope.kt)
helps to turn a `MoleculePresenter` into a `Presenter`, which then exposes a `StateFlow`:
```kotlin
val stateFlow = moleculeScope
.launchMoleculePresenter(
presenter = myPresenter,
input = Unit,
)
.model
```
!!! warning
`MoleculeScope` wraps a `CoroutineScope`. The presenter keeps running, recomposing and producing new models
until the `MoleculeScope` is canceled. If the `MoleculeScope` is never canceled, then presenters leak and will
cause issues later.
Use [`MoleculeScopeFactory`](https://github.com/amzn/app-platform/blob/main/presenter-molecule/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/molecule/MoleculeScopeFactory.kt)
to create a new `MoleculeScope` instance and call `cancel()` when you don't need it anymore.
On Android an implementation using `ViewModels` may look like this:
```kotlin
class MainActivityViewModel(
moleculeScopeFactory: MoleculeScopeFactory,
myPresenter: MyPresenter,
) : ViewModel() {
private val moleculeScope = moleculeScopeFactory.createMoleculeScope()
// Expose the models for consumption.
val models = moleculeScope
.launchMoleculePresenter(
presenter = myPresenter,
input = Unit
)
.models
override fun onCleared() {
moleculeScope.cancel()
}
}
```
!!! info
By default `MoleculeScope` uses the main thread for running presenters and
[`RecompositionMode.ContextClock`](https://github.com/cashapp/molecule/blob/trunk/molecule-runtime/src/commonMain/kotlin/app/cash/molecule/RecompositionMode.kt),
meaning a new model is produced only once per UI frame and further changes are conflated.
This behavior can be changed by creating a custom `MoleculeScope`, e.g. tests make use of this:
```kotlin
fun TestScope.moleculeScope(
coroutineContext: CoroutineContext = EmptyCoroutineContext
): MoleculeScope {
val scope = backgroundScope + CoroutineName("TestMoleculeScope") + coroutineContext
return MoleculeScope(scope, RecompositionMode.Immediate)
}
```
## Testing
A [`test()`](https://github.com/amzn/app-platform/blob/main/presenter-molecule/testing/src/commonMain/kotlin/software/amazon/app/platform/presenter/molecule/TestPresenter.kt)
utility function is provided to make testing `MoleculePresenters` easy using the [Turbine](https://github.com/cashapp/turbine/)
library:
```kotlin
class LoginPresenterImplTest {
@Test
fun `after 1 second the user is logged in after pressing the login button`() = runTest {
val userManager = FakeUserManager()
LoginPresenterImpl(userManager).test(this) {
val firstModel = awaitItem()
...
}
}
}
```
The `test()` function uses the `TestScope.backgroundScope` to run the presenter.
??? example "Sample"
The sample application implements multiple tests for its presenters, e.g.
[`LoginPresenterImplTest`](https://github.com/amzn/app-platform/blob/main/sample/login/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/login/LoginPresenterImplTest.kt),
[`NavigationPresenterImplTest`](https://github.com/amzn/app-platform/blob/main/sample/navigation/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/navigation/NavigationPresenterImplTest.kt)
and [`UserPagePresenterImplTest`](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/user/UserPagePresenterImplTest.kt).
## Back gestures
`Presenters` support back gestures with a similar API in terms of syntax and semantic to Compose Multiplatform. Any
`Presenter` can call these functions:
```kotlin
@Composable
fun present(input: Unit): Model {
BackHandlerPresenter {
// Handle a back press.
}
PredictiveBackHandlerPresenter { progress: Flow ->
// code for gesture back started
try {
progress.collect { backevent ->
// code for progress
}
// code for completion
} catch (e: CancellationException) {
// code for cancellation
}
}
}
```
!!! warning
Notice `Presenter` suffix in these function names. These functions should not be confused with `BackHandler {}` and
`PredictiveBackHandler {}` coming from Compose Multiplatform or Compose UI Android, which would fail at runtime
when called from a `Presenter`.
Calling these functions requires `BackGestureDispatcherPresenter` to be setup as composition local. This is usually
done from the root presenter in your hierarchy. An instance of `BackGestureDispatcherPresenter` is provided by App
Platform in the application scope and can be injected:
```kotlin hl_lines="3 7 8 9"
@Inject
class RootPresenter(
private val backGestureDispatcherPresenter: BackGestureDispatcherPresenter,
) : MoleculePresenter {
@Composable
override fun present(input: Unit): Model {
return returningCompositionLocalProvider(
LocalBackGestureDispatcherPresenter provides backGestureDispatcherPresenter
) {
// Call other child presenters.
}
}
}
```
The last step is to forward back gestures from the UI layer to `Presenters` to invoke the callbacks in the
`Presenters`. Here again it's recommended to do this from within the root `Renderer`:
```kotlin hl_lines="4 8"
@Inject
@ContributesRenderer
class RootPresenterRenderer(
private val backGestureDispatcherPresenter: BackGestureDispatcherPresenter,
) : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
backGestureDispatcherPresenter.ForwardBackPressEventsToPresenters()
// Call other child renderers.
}
}
```
A similar built-in integration is provided for Android Views. There it's recommended to call this function from each
Android `Activity`:
```kotlin hl_lines="6"
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
backGestureDispatcherPresenter.forwardBackPressEventsToPresenters(this)
// ...
}
}
```
Unit tests verifying the behavior of a `Presenter` using the back handler APIs need to provide the composition local
as well. This can be achieved by wrapping the `Presenter` with `withBackGestureDispatcher()`:
```kotlin
class MyPresenterTest {
@Test
fun `test back handler`() = runTest {
val presenter = MyPresenter()
presenter.withBackGestureDispatcher().test(this) {
// Verify the produced models from the presenter.
}
}
}
```
??? example "Sample"
The `BackHandlerPresenter {}` call has been integrated in the sample application with this recommended setup. All
necessary changes are part of this [commit](https://github.com/amzn/app-platform/pull/84/commits/a807a5673973eae26940cd1130dad836cb3dbd43).
The same setup has been integrated in the recipes app part of this [commit](https://github.com/amzn/app-platform/pull/82/commits/fce1b3fbc0b2683ec6a93a499694f914bac34b18)
as well.
## Compose runtime
One of the major benefits of using Compose through Molecule is how the framework turns reactive streams such as
`Flow` and `StateFlow` into imperative code, which then becomes easier to understand, write and maintain.
Composable functions have a lifecycle, they enter a composition (the presenter starts to be used) and leave
a composition (the presenter is no longer used). Properties can be made reactive and trigger creating a
new `Model` whenever they change.
### Lifecycle
This example contains two child presenters:
```kotlin
class OnboardingPresenterImpl(
private val lazyLoginPresenter: () -> LoginPresenter,
private val lazyRegistrationPresenter: () -> RegistrationPresenter,
) : OnboardingPresenter {
@Composable
fun present(input: Unit): BaseModel {
...
return if (mustRegister) {
val registrationPresenter = remember { lazyRegistrationPresenter() }
registrationPresenter.present(Unit)
} else {
val loginPresenter = remember { lazyLoginPresenter() }
loginPresenter.present(Unit)
}
}
}
```
On the first composition, when `OnboardingPresenterImpl.present()` is called for the first time, the lifecycle of
`OnboardingPresenterImpl` starts. Let’s assume `mustRegister` is true, then `RegistrationPresenter` gets called
and its lifecycle starts as well. In the example when `mustRegister` switches to false, then `RegistrationPresenter`
leaves the composition and its lifecycle ends. `LoginPresenter` enters the composition and its lifecycle starts.
If the parent presenter of `OnboardingPresenterImpl` stops calling this presenter, then `OnboardingPresenterImpl`
and `LoginPresenter` would leave composition and both of their lifecycles end.
### State
[Google’s guide](https://developer.android.com/develop/ui/compose/state) for state management is a good starting
point. APIs most often used are [`remember()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#remember(kotlin.Function0)),
[`mutableStateOf()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#mutableStateOf(kotlin.Any,androidx.compose.runtime.SnapshotMutationPolicy)),
[`collectAsState()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.StateFlow).collectAsState(kotlin.coroutines.CoroutineContext))
and [`produceState()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#produceState(kotlin.Any,kotlin.coroutines.SuspendFunction1)).
```kotlin
@Composable
fun present(input: Unit): Model {
var toggled: Boolean by remember { mutableStateOf(false) }
return Model(
text = if (toggled) "toggled" else "not toggled",
) {
when (it) {
is ToggleClicked -> toggled = !toggled
}
}
}
```
In this example, whenever the Presenter receives the `ToggleClicked` event, then the state `toggled` changes.
This triggers a recomposition in the Compose runtime and will call `present()` again to compute a new `Model`.
`Flows` can easily be observed using `collectAsState()`:
```kotlin hl_lines="10"
interface AccountManager {
val currentAccount: StateFlow
}
class AmazonLoginPresenter(
private val accountManager: AccountManager
): LoginPresenter {
@Composable
fun present(input: Unit): Model {
val account: Account by accountManager.currentAccount.collectAsState()
...
}
}
```
Whenever the `currentAccount` Flow emits a new `Account`, then the Compose runtime will trigger a recomposition
and a new `Model` will be computed.
### Side effects
It’s recommended to read [Google’s guide](https://developer.android.com/jetpack/compose/side-effects). Since
composable functions come with a lifecycle, async operations can safely be launched and get automatically torn
down when the `Presenter` leaves the composition. Commonly used APIs are
[`LaunchedEffect()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#LaunchedEffect(kotlin.Any,kotlin.coroutines.SuspendFunction1)),
[`DisposableEffect()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#DisposableEffect(kotlin.Any,kotlin.Function1))
and [`rememberCoroutineScope()`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#rememberCoroutineScope(kotlin.Function0)).
```kotlin
@Composable
fun present(input: Unit): Model {
LaunchedEffect(key) {
// This is within a CoroutineScope and suspending functions can
// be called:
flowOf(1, 2, 3).collect { ... }
}
}
```
If the `key` changes between compositions, then a new coroutine is launched and the previous one canceled. For more
details see [here](https://developer.android.com/jetpack/compose/side-effects#launchedeffect).
This is an example for how one would use `rememberCoroutineScope()`:
```kotlin
@Composable
fun present(input: Unit): Model {
val coroutineScope = rememberCoroutineScope()
return Model() {
when (it) {
is OnClick -> coroutineScope.launch { ... }
}
}
}
```
When the `Presenter` leaves composition, then all jobs launched by this coroutine scope get canceled. For more
details see [here](https://developer.android.com/jetpack/compose/side-effects#remembercoroutinescope).
## Recipes
There are common scenarios you may encounter when using `Presenters`.
!!! info
The recipes below are not part of the App Platform API and we look for feedback. The solutions are either
implemented in the Recipes or Sample app. Please let us know if these solutions work for you or which use cases
you're missing.
The [Recipes app](index.md#web-recipe-app) and [Sample app](index.md#web-clickable) can be tested in the browser.
### Save `Presenter` state
`Presenters` can make full use of the Compose runtime, e.g. using `remember { }` and `mutableStateOf()`. But when a
`Presenter` leaves the composition and no longer is part of the hierarchy, then it loses its state and would be called
with the initial state the next time.
```kotlin
@Composable
fun present(input: Unit): Model {
val showLogin = ...
val model = if (showLogin) {
loginPresenter.present(Unit)
} else {
registerPresenter.present(Unit)
}
return model
}
```
Take this function for example. Every time `showLogin` is toggled then either `loginPresenter` or `registerPresenter`
is called with their initial state. These presenters only remember their state, if `showLogin` doesn't change.
The Compose runtime provides `rememberSaveable { }` and `SaveableStateHolder` as solution to save and restore instance
state within a process or across process death. The Recipes app
[ported `SaveableStateHolder`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/saveable/ReturningSaveableStateHolder.kt)
to work for `@Composable` functions that must return a value. `Presenters` wrapped with a
`ReturningSaveableStateHolder` can use `rememberSaveable { }` to restore state even after they weren't part of the
hierarchy anymore:
```kotlin
@Composable
fun present(input: Unit): Model {
val showLogin = ...
val saveableStateHolder = rememberReturningSaveableStateHolder()
val presenter = if (showLogin) loginPresenter else registerPresenter
return saveableStateHolder.SaveableStateProvider(key = presenter) {
presenter.present(Unit)
}
}
```
State wrapped in `rememberSaveable { }` in `LoginPresenter` and `RegisterPresenter` will be preserved no
matter how often `showLogin` is toggled.
### `Presenter` backstack
With `Presenters` it's easy to implement model driven navigation. Which `Presenter` is shown on screen is part of the
business logic.
```kotlin
@Composable
fun present(input: Unit): Model {
val showLogin = ...
val model = if (showLogin) {
loginPresenter.present(Unit)
} else {
registerPresenter.present(Unit)
}
return model
}
```
This pattern can be generalized:
```kotlin
interface NavigationManager {
val currentPresenter: StateFlow>
fun navigateTo(presenter: MoleculePresenter)
}
@Inject
class NavigationPresenter(val navigationManager: NavigationManager) : MoleculePresenter {
@Compose
fun present(input: Unit): BaseModel {
val presenter by navigationManager.currentPresenter.collectAsState()
return presenter.present(Unit)
}
}
```
This solution always shows the `Presenter` for which `navigateTo()` was called last. This function can be called from
anywhere in the app.
Another solution is a backstack of `Presenters`, where `Presenters` can be pushed to the stack and the top
most `Presenter` can be popped from the stack. The Recipes app
[implemented this navigation pattern](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/backstack/PresenterBackstackScope.kt)
with an easy to use `presenterBackstack { }` function:
```kotlin
class CrossSlideBackstackPresenter(
private val initialPresenter: MoleculePresenter
) : MoleculePresenter {
@Composable
override fun present(input: Unit): Model {
return presenterBackstack(initialPresenter) { model ->
// Pop the top presenter on a back press event.
BackHandlerPresenter(enabled = lastBackstackChange.value.backstack.size > 1) {
pop()
}
Model(delegate = model, backstackScope = this)
}
}
}
```
`presenterBackstack { }` provides
[PresenterBackstackScope](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/backstack/PresenterBackstackScope.kt),
which allows you to `push()` and `pop()` presenters.
[Child presenters](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/backstack/presenter/BackstackChildPresenter.kt#L38)
wrapped in this function get access to this scope using a composition local:
```kotlin
@Composable
override fun present(input: Unit): Model {
val backstack = checkNotNull(LocalBackstackScope.current)
...
return Model() {
when (it) {
Event.AddPresenterToBackstack -> backstack.push(BackstackChildPresenter())
}
}
}
```
[`CrossSlideBackstackPresenter`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/backstack/CrossSlideBackstackPresenter.kt)
from the Recipe app goes one step further and integrates the `BackHandlerPresenter { }` API to pop presenters from the
stack when the back button is pressed. Its
[`Renderer`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/backstack/CrossSlideBackstackRenderer.kt)
implements a slide animation whenever a presenter is pushed to the stack or popped from the stack.
### `CompositionLocal`
Both the `BackHandlerPresenter { }` integration for back button presses and the backstack recipe for navigation leverage
[Compose's `CompositionLocal` feature](https://developer.android.com/develop/ui/compose/compositionlocal#creating).
This is a powerful mechanism to provide state from a parent presenter to nested child presenters even deep down in
the stack without relying on the `Input` parameter of presenters or providing
dependencies through the constructor. Another benefit is that `CompositionLocals` are embedded in the presenter tree
and multiple instances can be provided for different parts of the tree or even be overridden, e.g. a parent presenter
may use a backstack, but then a child presenter may provide its own backstack for its child presenters.
A common implementation may look like this:
```kotlin
class YourType
public val LocalYourType: ProvidableCompositionLocal = compositionLocalOf { null }
class ParentPresenter : MoleculePresenter {
@Composable
override fun present(input: Unit): Model {
val yourType = remember { YourType() }
return returningCompositionLocalProvider(
LocalYourType provides yourType
) {
// ... call child presenters
}
}
}
class ChildPresenter : MoleculePresenter {
@Composable
override fun present(input: Unit): Model {
val yourType = checkNotNull(LocalYourType.current)
...
}
}
```
While `CompositionLocals` are powerful, their biggest downsides are unit tests. In a unit test for `ChildPresenter`
a value for `LocalYourType.current` must be provided, otherwise the call will throw an exception.
### App Bar
The Recipes app implements an app bar for all its screens and allows child presenters to change the content.
There are multiple ways to implement the app bar and decompose the different screen elements. One way is using
[Templates](template.md), where one slot in the template is reserved for the app bar model. A specific `Presenter`
could be responsible for providing this model:
```kotlin
sealed interface SampleAppTemplate : Template {
data class FullScreenTemplate(
val appBarModel: AppBarModel
val content: BaseModel,
) : SampleAppTemplate
}
class SampleAppTemplatePresenter(
private val appBarPresenter: AppBarPresenter,
private val rootPresenter: MoleculePresenter,
) : MoleculePresenter {
@Composable
fun present(input: Unit): SampleAppTemplate {
val contentModel = rootPresenter.present(Unit)
return contentModel.toTemplate { model ->
val appBarModel = appBarPresenter.present(Unit)
FullScreenTemplate(appBarModel, contentModel)
}
}
}
```
The `SampleAppTemplateRenderer` has access to `appBarModel` from the `FullScreenTemplate` and can use the model
to configure the app bar UI.
The Recipe app has chosen a different implementation, where any `BaseModel` class from a `Presenter` can implement the
specific [`AppBarConfigModel`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/appbar/AppBarConfigModel.kt)
interface, which provides the configuration for the app bar. Implementing this interface is optional:
```kotlin
class MenuPresenter : MoleculePresenter {
@Composable
override fun present(input: Unit): Model {
...
}
data class Model(
private val menuItems: List,
) : BaseModel, AppBarConfigModel {
override fun appBarConfig(): AppBarConfig {
return AppBarConfig(title = "Menu items", menuItems = menuItems)
}
}
}
```
If a `BaseModel` implementing `AppBarConfigModel` bubbles all the way up to the
[`RootPresenter`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/template/RootPresenter.kt),
then the `BaseModel` from the child `Presenter` will provide the config for the `Template` or otherwise the
`RootPresenter` will provide a default:
```kotlin
return contentModel.toTemplate { model ->
val appBarConfig =
if (model is AppBarConfigModel) {
model.appBarConfig().copy(backArrowAction = backArrowAction)
} else {
AppBarConfig(title = AppBarConfig.DEFAULT.title, backArrowAction = backArrowAction)
}
RecipesAppTemplate.FullScreenTemplate(model, appBarConfig)
}
```
### Navigation 3
The [Navigation 3 library](https://developer.android.com/guide/navigation/navigation-3) can be used with App Platform.
For idiomatic navigation App Platform [recommends](presenter.md#model-driven-navigation) handling navigation events in
`Presenters`. `Presenters` are composable, build a tree and can delegate which `Presenter` is shown on screen to child
`Presenters`. This is how App Platform implements a unidirectional dataflow. The downside of Navigation 3 is that
it pushes navigation logic into the Compose UI layer, which is against App Platform's philosophy of handling navigation
in the business logic. With the right integration strategy, this downside can be mitigated.
The Recipes app manages the backstack of `Presenters` in the parent
[`Navigation3HomePresenter`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/nav3/Navigation3HomePresenter.kt)
and forwards the backstack and options to modify the stack to the `Renderer`. Note that the `Model` is computed for
each `Presenter` in the backstack:
```kotlin
@Composable
override fun present(input: Unit): Model {
val backstack = remember {
mutableStateListOf>().apply {
// There must be always one element.
add(Navigation3ChildPresenter(index = 0, backstack = this))
}
}
return Model(backstack = backstack.map { it.present(Unit) }) {
when (it) {
Event.Pop -> {
backstack.removeAt(backstack.size - 1)
}
}
}
}
```
The [`Renderer`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/nav3/Navigation3HomeRenderer.kt)
wraps the backstack in a `NavDisplay` and forwards back gestures to the `Presenter`. There is a unique `NavEntry`
for each position in the stack and the individual `Renderer` for each `Model` is invoked:
```kotlin
@Inject
@ContributesRenderer
class Navigation3HomeRenderer(private val rendererFactory: RendererFactory) : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
// Use the position of the model in the backstack as key for `NavDisplay`. This way
// we can update models without Navigation 3 treating those changes as a new screen.
val backstack = model.backstack.mapIndexed { index, _ -> index }
NavDisplay(
backStack = backstack,
onBack = { model.onEvent(Event.Pop) },
entryProvider = { key ->
NavEntry(key) {
val model = model.backstack[it]
rendererFactory.getComposeRenderer(model).renderCompose(model)
}
},
)
}
}
```
With this integration handling of the backstack is managed in the `Presenter` and testable.
??? info "Alternative integration"
If a unidirectional dataflow isn't required, an alternative integration is making each `NavEntry` a unique
`Presenter` root and compute the `Model` directly using the `Presenter`. For the reasons mentioned we don't
recommend this setup.
```kotlin
data object List
data object Detail
@Inject
@ContributesRenderer
class Navigation3Renderer(
private val listPresenter: ListPresenter,
private val detailPresenter: DetailPresenter,
private val rendererFactory: RendererFactory,
) : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
val backstack = remember { mutableStateListOf(List) }
NavDisplay(
backStack = backstack,
onBack = { backstack.removeAt(backstack.size - 1) },
entryProvider =
entryProvider {
entry {
val model = listPresenter.present(Unit)
rendererFactory.getComposeRenderer(model).renderCompose(model)
}
entry {
val model = detailPresenter.present(Unit)
rendererFactory.getComposeRenderer(model).renderCompose(model)
}
},
)
}
}
```
### SwiftUI
#### `Presenters` and SwiftUI `Views`
In iOS it's possible to connect `Presenters` to SwiftUI `Views` so `Presenter` logic can be shared while keeping UI
native. The Recipes app demonstrates a [set of Swift APIs](https://github.com/amzn/app-platform/tree/main/recipes/recipesIosApp/recipesIosApp/PresenterViews)
that demonstrate how to launch a `Presenter` and render SwiftUI `Views` in the iOS flavor. Note that App Platform
does not provide an API equivalent of SwiftUI `Renderers`. As such, we need to decide how to observe the flow of models
from a given `Presenter` and create `Views` from them.
To obtain an observable stream of models, `Presenter` can be extended to provide an `AsyncThrowingStream` from the
model `StateFlow`. It's also possible to implement a convenient extension of `Flow` so we can convert any `Flow` to an
`AsyncThrowingStream`.
```swift
extension Presenter {
func viewModels(ofType type: Model.Type) -> AsyncThrowingStream {
model
.values()
.compactMap { $0 as? Model }
.asAsyncThrowingStream()
}
}
extension Kotlinx_coroutines_coreFlow {
/// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check.
func values() -> AsyncThrowingStream {
let collector = Kotlinx_coroutines_coreFlowCollectorImpl()
collect(collector: collector, completionHandler: collector.onComplete(_:))
return collector.values
}
}
```
Given a `Model` there are multiple ways to implement association with some SwiftUI `View`. The Recipes app chooses to
create a [`protocol`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/protocols/) for
view creation and extend `BaseModel` to create views under the requirement of its conformance:
```swift
protocol PresenterViewModel {
associatedtype Renderer : View
@ViewBuilder @MainActor func makeViewRenderer() -> Self.Renderer
}
extension BaseModel {
@MainActor func getViewRenderer() -> AnyView {
guard let viewModel = self as? (any PresenterViewModel) else {
assertionFailure("ViewModel \(self) does not conform to `PresenterViewModel`")
// This is an implementation detail. If crashing is preferred even in production builds, `fatalError(..)`
// can be used instead
return AnyView(Text("Error, some ViewModel was not implemented!"))
}
return AnyView(viewModel.makeViewRenderer())
}
}
```
??? info "Alternate implementation"
We can also create a `View` registry:
```swift
public class PresenterViewRegistry {
@MainActor private var registry: [ObjectIdentifier: (Any) -> AnyView] = [:]
public init(registry: [ObjectIdentifier : (Any) -> AnyView] = [:]) {
self.registry = registry
}
public static var shared: PresenterViewRegistry = PresenterViewRegistry()
}
@MainActor public extension PresenterViewRegistry {
func registerViewForModelType(_ type: Model.Type, makeView: @escaping (Model) -> Content) {
let typeID = ObjectIdentifier(Model.self)
registry[typeID] = { model in
AnyView(makeView(model as! Model))
}
}
func makeViewForModel(_ model: Model) -> some View {
let type = type(of: model as Any)
let typeID = ObjectIdentifier(type)
if let makeView = registry[typeID] {
return makeView(model)
}
fatalError("Could not find view builder for \(type). Add it to the registry.")
}
}
```
The registry can be stored in an `Environment` property wrapper. This is similar to how `@ContributesRenderer` works
under the hood, though without an equivalent App Platform API the heavy lifting on registration and registry
lifecycle management falls to consumers. Due to these reasons we generally recommend to use the protocol setup.
#### Navigation with `Presenters` and SwiftUI
SwiftUI provides [navigation containers](https://developer.apple.com/documentation/swiftui/navigation) to enable
movement between different part of an app's view hierarchy. Similar to `Navigation 3`, SwiftUI's navigation containers
push navigation logic to the UI layer, which is against App Platform's philosophy of handling navigation in business
logic. However, to support navigation with SwiftUI `Views` and `Presenters`, it is recommended to integrate with
SwiftUI's navigation offerings. This SwiftUI keeps the determination of some completed back gesture an implementation
detail, and we want ensure that all back events are handled appropriately and the user experience feels truly native.
!!! note
We provide a recipe for integration with `NavigationStack` for single column navigation based on back gesture. For
other kinds of navigation with `NavigationSplitView` or `NavigationLink` it is possible to integrate following our
[model driven navigation](https://amzn.github.io/app-platform/presenter/#model-driven-navigation) pattern. However,
we don't provide an explicit recipe for it. If you're missing some use cases here, please let us know.
The Recipes app demonstrates how SwiftUI navigation APIs can be used while following App Platform's philosophy of
unidirectional data flow. As navigation is a part of business logic, the recipe [implements navigation with
a backstack of `Presenters`](https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt).
The root `Presenter` responsible for the `Presenter` backstack computes the `Model` backstack:
```kotlin
@Composable
override fun present(input: Unit): Model {
val backstack = remember {
mutableStateListOf>().apply {
// There must be always one element.
add(SwiftUiChildPresenter(index = 0, backstack = this))
}
}
return Model(modelBackstack = backstack.map { it.present(Unit) }) {
when (it) {
is Event.BackstackModificationEvent -> {
val updatedBackstack = it.indicesBackstack.map { index -> backstack[index] }
backstack.clear()
backstack.addAll(updatedBackstack)
}
}
}
}
```
The `Presenter` forwards the `Models` and event callbacks to a SwiftUI `View`, which
integrates these models with a [`NavigationStack`](https://github.com/amzn/app-platform/blob/main/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift).
Note that to integrate we create a [`Binding`](https://developer.apple.com/documentation/swiftui/binding) that is passed
in to the `NavigationStack`. The `Binding's` value type must conform to `Hashable` and by default `BaseModel` does not
conform. To resolve this in the recipe we simply represent each `Model` by the index of its position in the `Model`
backstack as we do not require more complex identifiers.
```swift
extension SwiftUiHomePresenter.Model {
func pathBinding() -> Binding<[Int]> {
.init {
// drop the first value of the backstack from the path because that should be the root view
Array(self.modelBackstack.indices.dropFirst())
} set: { modifiedIndices in
// the resulting backstack indices the presenter should compute on is the first index (0) that was
// dropped as well as the remaining indices post modification
let indicesBackstack = [0] + modifiedIndices.map { $0.toKotlinInt() }
self.onEvent(
SwiftUiHomePresenterEventBackstackModificationEvent (
indicesBackstack: indicesBackstack
)
)
}
}
}
private struct NavigationStackView: View {
var backstack: [BaseModel]
var model: SwiftUiHomePresenter.Model
init(model: SwiftUiHomePresenter.Model) {
self.backstack = model.modelBackstack
self.model = model
}
var body: some View {
NavigationStack(path: model.pathBinding()) {
backstack[0].getViewRenderer()
.navigationDestination(for: Int.self) { index in
backstack[index].getViewRenderer()
}
}
}
}
```
================================================
FILE: docs/renderer.md
================================================
# Renderer
!!! note
App Platform has a generic `Renderer` interface that can be used for multiple UI layer implementations.
Compose Multiplatform and Android Views are stable and supported out of the box. However, Compose Multiplatform is
an opt-in feature through the Gradle DSL and must be explicitly enabled. The default value is `false`.
```groovy
appPlatform {
enableComposeUi true
}
```
## Renderer basics
A [`Renderer`](https://github.com/amzn/app-platform/blob/main/renderer/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/Renderer.kt)
is the counterpart to a `Presenter`. It consumes `Models` and turns them into UI, which is shown on screen.
```kotlin
interface Renderer {
fun render(model: ModelT)
}
```
The `Renderer` interface is rarely used directly, instead platform specific implementations like
[`ComposeRenderer`](https://github.com/amzn/app-platform/blob/main/renderer-compose-multiplatform/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/ComposeRenderer.kt)
for [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) and
[`ViewRenderer`](https://github.com/amzn/app-platform/blob/main/renderer-android-view/public/src/androidMain/kotlin/software/amazon/app/platform/renderer/ViewRenderer.kt)
for Android are used. App Platform doesn’t provide any other implementations for now, e.g. a SwiftUI or UIKit
implementation for iOS is missing.
```kotlin title="ComposeRenderer"
@ContributesRenderer
class LoginRenderer : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
if (model.loginInProgress) {
CircularProgressIndicator()
} else {
Text("Login")
}
}
}
```
```kotlin title="ViewRenderer"
@ContributesRenderer
class LoginRenderer : ViewRenderer() {
private lateinit var textView: TextView
override fun inflate(
activity: Activity,
parent: ViewGroup,
layoutInflater: LayoutInflater,
initialModel: Model,
): View {
return TextView(activity).also { textView = it }
}
override fun renderModel(model: Model) {
textView.text = "Login"
}
}
```
!!! warning
Note that `ComposeRenderer` like `ViewRenderer` implements the common `Renderer` interface, but calling the
`render(model)` function [is an error](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/renderer-compose-multiplatform/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/ComposeRenderer.kt#L52-L58).
Instead, `ComposeRenderer` defines its own function to preserve the composable context:
```kotlin
@Composable
fun renderCompose(model: ModelT)
```
In practice this is less of a concern, because the `render(model)` function is deprecated and hidden and callers
only see the `renderCompose(model)` function.
Renderers are composable and can build hierarchies similar to `Presenters`. The parent renderer is responsible for
calling `render()` on the child renderer:
```kotlin
data class ParentModel(
val childModel: ChildModel
): BaseModel
class ParentRenderer(
private val childRenderer: ChildRenderer
): Renderer {
override fun render(model: ParentModel) {
childRenderer.render(model.childModel)
}
}
```
!!! note
Injecting concrete child `Renderers` is possible, but less common. More frequently `RendererFactory` is injected
to obtain a `Renderer` instance for a `Model`.
A `Renderer` sends events back to the `Presenter` through the `onEvent` lambda on a Model.
```kotlin hl_lines="6"
@ContributesRenderer
class LoginRenderer : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
Button(
onClick = { model.onEvent(LoginPresenter.Event.Login("Demo")) },
) {
Text("Login")
}
}
}
```
??? example "Sample"
The sample app implements multiple `ComposeRenderers`, e.g. [`LoginRenderer`](https://github.com/amzn/app-platform/blob/main/sample/login/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/login/LoginRenderer.kt),
[`UserPageListRenderer`](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageListRenderer.kt)
and [`UserPageDetailRenderer`](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailRenderer.kt).
## `RendererFactory`
How `Renderers` are initialized depends on [`RendererFactory`](https://github.com/amzn/app-platform/blob/main/renderer/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/RendererFactory.kt),
which only responsibility is to create and cache `Renderers` based on the given model. App Platform comes with three
different implementations:
[`ComposeRendererFactory`](https://github.com/amzn/app-platform/blob/main/renderer-compose-multiplatform/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/ComposeRendererFactory.kt)
: `ComposeRendererFactory` is an implementation for Compose Multiplatform and can be used on all supported
platforms. It can only create instances of `ComposeRenderer`.
[`AndroidRendererFactory`](https://github.com/amzn/app-platform/blob/main/renderer-android-view/public/src/androidMain/kotlin/software/amazon/app/platform/renderer/AndroidRendererFactory.kt)
: `AndroidRendererFactory` is only suitable for Android. It can be used to create `ViewRenderer` instances and its
subtypes. It does not support `ComposeRenderer`. Use `ComposeAndroidRendererFactory` if you need to mix and
match `ViewRenderer` with `ComposeRenderer`.
[`ComposeAndroidRendererFactory`](https://github.com/amzn/app-platform/blob/main/renderer-compose-multiplatform/public/src/androidMain/kotlin/software/amazon/app/platform/renderer/ComposeAndroidRendererFactory.kt)
: `ComposeAndroidRendererFactory` is only suitable for Android when using `ComposeRenderer` together with
`ViewRenderer`. The factory wraps the Renderers for seamless interop.
### `@ContributesRenderer`
All factory implementations rely on Metro or `kotlin-inject-anvil` to discover and initialize
renderers. When the factory is created, it builds the generated renderer graph or component, whose
parent is the app graph or component. That generated type lazily provides all renderers using the
multibindings feature. To participate in the lookup, renderers must tell Metro or
`kotlin-inject-anvil` which models they can render. This is done through a generated graph or
component interface, which is automatically added to the renderer scope by using the
[`@ContributesRenderer` annotation](https://github.com/amzn/app-platform/blob/main/kotlin-inject-extensions/contribute/public/src/commonMain/kotlin/software/amazon/app/platform/inject/ContributesRenderer.kt).
Which `Model` type is used for the binding is determined based on the super type. In the following example
`LoginPresenter.Model` is used.
```kotlin
@ContributesRenderer
class LoginRenderer : ComposeRenderer()
```
??? info "Generated code"
=== "Metro"
The `@ContributesRenderer` annotation generates following code.
```kotlin
@ContributesTo(RendererScope::class)
interface LoginRendererGraph {
@Provides
public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRenderer(): LoginRenderer = LoginRenderer()
@Provides
@IntoMap
@RendererKey(LoginPresenter.Model::class)
public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModel(renderer: Provider): Renderer<*> = renderer()
@Provides
@IntoMap
@ForScope(scope = RendererScope::class)
@RendererKey(LoginPresenter.Model::class)
public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModelKey(): KClass> = LoginRenderer::class
}
```
=== "kotlin-inject-anvil"
The `@ContributesRenderer` annotation generates following code.
```kotlin
@ContributesTo(RendererScope::class)
interface LoginRendererComponent {
@Provides
public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRenderer(): LoginRenderer = LoginRenderer()
@Provides
@IntoMap
public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModel(renderer: () -> LoginRenderer): Pair, () -> Renderer<*>> = LoginPresenter.Model::class to renderer
@Provides
@IntoMap
@ForScope(scope = RendererScope::class)
public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModelKey(): Pair, KClass>> = LoginPresenter.Model::class to LoginRenderer::class
}
```
### Creating `RendererFactory`
The `RendererFactory` should be created and cached in the platform specific UI context, e.g. an iOS `UIViewController`
or Android `Activity`.
```kotlin title="iOS Compose Multiplatform"
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
ComposeUIViewController {
// Only a single factory is needed.
val rendererFactory = remember { ComposeRendererFactory(rootScopeProvider) }
...
}
```
```kotlin title="Android Activity"
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val rendererFactory =
ComposeAndroidRendererFactory(
rootScopeProvider = application as RootScopeProvider,
activity = this,
parent = findViewById(R.id.main_container),
)
...
}
}
```
??? example "Sample"
The sample app uses `ComposeAndroidRendererFactory` in [Android application](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/androidMain/kotlin/software/amazon/app/platform/sample/MainActivity.kt#L30-L35)
and `ComposeRendererFactory` for [iOS](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/iosMain/kotlin/software/amazon/app/platform/sample/MainViewController.kt#L40)
and [Desktop](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/desktopMain/kotlin/software/amazon/app/platform/sample/DesktopApp.kt#L36).
### Creating `Renderers`
Based on a `Model` instance or `Model` type a `RendererFactory` can create a new `Renderer` instance. The
`getRenderer()` function creates a `Renderer` only once and caches the instance after that. This makes the caller side
simpler. Whenever a new `Model` is available get the `Renderer` for the `Model` and render the content on screen.
```kotlin title="iOS Compose Multiplatform"
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
ComposeUIViewController {
// Only a single factory is needed.
val rendererFactory = remember { ComposeRendererFactory(rootScopeProvider) }
val model = presenter.present(Unit)
val renderer = factory.getComposeRenderer(model)
renderer.renderCompose(model)
}
```
!!! note
Note that `getRenderer()` for `ComposeRendererFactory` returns a `ComposeRenderer`. For a `ComposeRenderer` the
`renderCompose(model)` function must be called and not `render(model)`.
```kotlin title="Android Activity"
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val rendererFactory = ComposeAndroidRendererFactory(...)
val models: StateFlow = ...
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
models.collect { model ->
val renderer = rendererFactory.getRenderer(model)
renderer.render(model)
}
}
}
}
}
```
### Injecting `RendererFactory`
The `RendererFactory` is provided in the `RendererComponent`, meaning it can be injected by any `Renderer`. This
allows you to create child renderers without knowing the concrete type of the model and injecting the child
renderers ahead of time:
```kotlin
@Inject
@ContributesRenderer
class SampleRenderer(
private val rendererFactory: RendererFactory
) : ComposeRenderer() {
@Composable
override fun Compose(model: Model) {
val childRenderer = rendererFactory.getComposeRenderer(model.childModel)
childRenderer.renderCompose(model.childModel)
}
}
```
??? example "Sample"
The sample app injects `RendererFactory` in [`ComposeSampleAppTemplateRenderer`](https://github.com/amzn/app-platform/blob/main/sample/templates/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/template/ComposeSampleAppTemplateRenderer.kt)
to create `Renderers` dynamically for unknown `Model` types. There is also an [Android sample implementation](https://github.com/amzn/app-platform/blob/main/sample/templates/impl/src/androidMain/kotlin/software/amazon/app/platform/sample/template/AndroidSampleAppTemplateRenderer.kt).
!!! note
Whenever a `Renderer` has an injected constructor parameter like `rendererFactory` in the sample above, then
the class must be annotated with `@Inject` in addition to `@ContributesRenderer`.
## Android support
Android Views are supported out of the box using `ViewRenderer`.
### Compose interop
If an Android app uses only Compose UI with `ComposeRenderer`, then it can use `ComposeRendererFactory` similar to
iOS and Desktop to create `ComposeRenderer` instances. However, if interop with Android Views is needed, then
`ComposeAndroidRendererFactory` must be used. `ComposeAndroidRendererFactory` makes it transparent which `Renderer`
implementation is used and interop is seamless. A `ComposeRenderer` that has a child `ViewRenderer` wraps the Android
view within a `AndroidView` composable function call. A `ViewRenderer` that has a child `ComposeRenderer` wraps the
Compose UI within a `ComposeView` Android View.
```kotlin
val rendererFactory = ComposeAndroidRendererFactory(...)
val renderer = rendererFactory.getRenderer(model)
render.render(model)
```
In this example the returned `Renderer` can be a `ComposeRenderer` or `ViewRenderer`, it would not matter and either
the Compose UI or Android Views would be rendered on screen. With the seamless interop it becomes easier to migrate
from Android Views to Compose UI by simply migrating renderers one by one.
### `ViewRenderer` subtypes
[`ViewBindingRenderer`](https://github.com/amzn/app-platform/blob/main/renderer-android-view/public/src/androidMain/kotlin/software/amazon/app/platform/renderer/ViewBindingRenderer.kt).
: View binding is supported out of the box using `ViewBindingRenderer`.
[`RecyclerViewViewHolderRenderer`](https://github.com/amzn/app-platform/blob/main/renderer-android-view/public/src/androidMain/kotlin/software/amazon/app/platform/renderer/RecyclerViewViewHolderRenderer.kt)
: `RecyclerViewViewHolderRenderer` allows you to implement elements of a `RecyclerView` as a `Renderer`.
## Unit tests
`ComposeRenderer` can easily be tested as unit tests on Desktop and iOS. In particular tests for Desktop are helpful
due to the fast build times. Various fake `Models` can be passed to the `Renderer` and the UI state based on the
model verified.
Testing `ComposeRenderer` or `ViewRenderer` for Android requires an Android device or emulator.
This test runs as a unit test on iOS and Desktop.
```kotlin
class LoginRendererTest {
@Test
fun `the login button is rendered when not logging in`() {
runComposeUiTest {
setContent {
val renderer = LoginRenderer()
renderer.renderCompose(LoginPresenter.Model(loginInProgress = false) {})
}
onNodeWithTag("loginProgress").assertDoesNotExist()
onNodeWithTag("loginButton").assertIsDisplayed()
}
}
}
```
??? example "Sample"
The sample app demonstrates this with the [`LoginRendererTest`](https://github.com/amzn/app-platform/blob/main/sample/login/impl/src/appleAndDesktopTest/kotlin/software/amazon/app/platform/sample/login/LoginRendererTest.kt).
To avoid duplicating the test in the `desktopTest` and `iosTest` source folders, the sample app has a custom
source set `appleAndDesktop`, which is a shared parent source set for `apple` and `desktop`.
================================================
FILE: docs/scope.md
================================================
# Scope
!!! note
Importing the `Scopes` API is an opt-in feature through the Gradle DSL. The default value is `false`.
```groovy
appPlatform {
addPublicModuleDependencies true
}
```
## Overview
Scopes define the boundary our software components operate in. A scope is a space with a well-defined lifecycle
that can be created and torn down. Scopes host other objects and can bind them to their lifecycle. Sub-scopes
or child scopes have the same or a shorter lifecycle as their parent scope.
A leak happens when one scope references another scope with a different lifecycle, e.g. a background thread,
which is started and finishes after a certain amount of time, references an Android `Activity` that is being
destroyed while the thread is still running. In this case the thread with the longer lifecycle leaks the
`Activity` with the shorter lifecycle. Another example is a singleton object, which lives as long as the
application process runs, keeping a strong reference to a user object, which should be released after the
user session expires.
Relying purely on platform specific scopes is problematic, because these scopes are out of our control.
When the platform decides to destroy one of its scopes, then we need to adjust and tear down our operations.
This doesn’t always align with our use cases, e.g. we might want to finish uploading data in the background
after the platform scope such as an `Activity` has been destroyed. Further, the platform scopes may not align
with how we'd represent logical scopes for our apps, e.g. they often lack a user scope. This forces us to
push objects and lifecycles into the application scope and this could cause data to leak across sessions and
trigger out of memory scenarios.
We need to be in charge of our own scopes. In simple terms this means having an object that can be created and
destroyed.
The App Platform provides the
[Scope](https://github.com/amzn/app-platform/blob/main/scope/public/src/commonMain/kotlin/software/amazon/app/platform/scope/Scope.kt)
interface to implement this concept.
```kotlin title="Scope.kt"
interface Scope {
val name: String
val parent: Scope?
fun buildChild(name: String, builder: (Builder.() -> Unit)? = null): Scope
fun children(): Set
fun isDestroyed(): Boolean
fun destroy()
fun register(scoped: Scoped)
fun getService(key: String): T?
}
```
## Creating a `Scope`
A `Scope` is created through the builder function. The
[Builder](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/scope/public/src/commonMain/kotlin/software/amazon/app/platform/scope/Scope.kt#L57)
allows you to add services before the Scope is finalized:
```kotlin
val rootScope = Scope.buildRootScope {
addService("key", service)
}
```
Child scopes are created using the parent:
```kotlin
rootScope.buildChild("user scope") {
addService("child-service", childService)
}
```
??? example "Sample"
The root scope is usually created when the application is launched. The sample application creates its
root scope [here](https://github.com/amzn/app-platform/blob/main/sample/app/src/commonMain/kotlin/software/amazon/app/platform/sample/DemoApplication.kt).
This `Scope` is never destroyed and stays alive for the entire app lifetime.
The sample application has a child scope for the logged in user. This `Scope` is created during
[login](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserManagerImpl.kt#L47-L52)
and [destroyed](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserManagerImpl.kt#L68)
during logout.
```kotlin
override fun login(userId: Long) {
...
val userComponent = userComponentFactory.createUserComponent(user)
val userScope =
rootScopeProvider.rootScope.buildChild("user-$userId") {
addKotlinInjectComponent(userComponent)
addCoroutineScopeScoped(userComponent.userScopeCoroutineScopeScoped)
}
...
userScope.register(userComponent.userScopedInstances)
}
override fun logout() {
val currentUserScope = user.value?.scope
...
currentUserScope?.destroy()
}
```
Tests usually leverage the test scope, which comes with better defaults for services such as the coroutine scope:
```kotlin
@Test
fun `my test`() = runTest {
val scope = Scope.buildTestScope(this)
}
// Or
@Test
fun `my test`() = runTestWithScope { scope ->
// `scope` is equivalent to calling `Scope.buildTestScope(this)`.
}
```
??? example "Sample"
Classes implementing the `Scoped` interface usually make use of the `runTestWithScope` function in their tests.
Notice in [this sample](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/user/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/user/SessionTimeoutTest.kt#L36-L48)
how `SessionTimeout`, which implements the `Scoped` interface, is registered in the `Scope`.
```kotlin hl_lines="7"
@Test
fun `on timeout the user is logged out`() = runTestWithScope { scope ->
val userManager = FakeUserManager()
userManager.login(1L)
val sessionTimeout = SessionTimeout(userManager, FakeAnimationHelper)
scope.register(sessionTimeout)
assertThat(userManager.user.value).isNotNull()
advanceTimeBy(SessionTimeout.initialTimeout + 1.milliseconds)
assertThat(userManager.user.value).isNull()
}
```
## Services
A scope can host other objects like an object graph from dependency injection frameworks and a coroutine scope.
The latter is especially helpful, because the coroutine scope can be canceled when our logical scope is destroyed
and all pending operations are torn down. Connecting our scopes with the dependency injection components makes
our dependency injection setup more flexible, because we’re in charge of instantiating components and can provide
extra objects like a user ID to the object graph. When a scope is destroyed we release the dependency injection
component and the memory can be reclaimed by the runtime. DI components and subcomponents form a tree, therefore
subcomponents can inject all types that are provided by parent components. The strong recommendation is to align
the component tree with the scope hierarchy.
While a service can be obtained through the `getService()` function, a more frequent pattern is to rely on
extension functions for stronger types. Similarly, an extension function on the `Builder` allows us to add a service
to a `Scope`.
```kotlin
interface MyService
private const val MY_SERVICE_KEY = "myService"
fun Scope.Builder.addMyService(service: MyService) {
addService(MY_SERVICE_KEY, service)
}
fun Scope.myService(): MyService {
return checkNotNull(getService(MY_SERVICE_KEY))
}
```
The App Platform comes with a coroutine scope service and an integration for
[Metro](https://zacsweers.github.io/metro) and
[kotlin-inject-anvil](https://github.com/amzn/kotlin-inject-anvil) as dependency injection
frameworks. Metro is the recommended default.
```kotlin
val rootScope = Scope.buildRootScope {
addCoroutineScopeScoped(coroutineScope)
addMetroDependencyGraph(metroDependencyGraph)
addKotlinInjectComponent(kotlinInjectComponent)
}
// Obtain service.
rootScope.coroutineScope()
rootScope.metroDependencyGraph()
rootScope.kotlinInjectComponent()
```
!!! warning
`Scopes` through their service mechanism implement the service locator pattern. With the provided dependency
injection framework usually it’s not needed to add custom services and it’s better to rely on dependency
injection instead.
### `CoroutineScope`
!!! info
By default, the IO dispatcher is used for all launched jobs for the provided `CoroutineScope`.
In tests when using `Scope.buildTestScope()` or `runTestWithScope` the `backgroundScope` is from the `TestScope`
is used by default and added to `Scope` instance.
It's strongly recommended to add a `CoroutineScope` to each `Scope`. App Platform provides a `CoroutineScope`
[by default for the `AppScope`](https://github.com/amzn/app-platform/blob/main/kotlin-inject/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent.kt).
It is important to register this `CoroutineScope` in the created app `Scope` instance in order to cancel the
`CoroutineScope` in case the `AppScope` ever gets destroyed. The same applies to any child scope.
=== "Metro"
```kotlin
@DependencyGraph(AppScope::class)
interface AppGraph {
/** The coroutine scope that runs as long as the app scope is alive. */
@ForScope(AppScope::class) val appScopeCoroutineScopeScoped: CoroutineScopeScoped // (1)!
}
fun createAppScope(appGraph: AppGraph): Scope {
return Scope.buildRootScope {
addMetroDependencyGraph(appGraph)
addCoroutineScopeScoped(appGraph.appScopeCoroutineScopeScoped)
}
}
```
1. `CoroutineScopeScoped` wraps a `CoroutineScope` in a `Scoped` instance. In `onExitScope()` of this instance the
`CoroutineScope` will be canceled.
=== "kotlin-inject-anvil"
```kotlin
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
interface AppComponent {
/** The coroutine scope that runs as long as the app scope is alive. */
@ForScope(AppScope::class) val appScopeCoroutineScopeScoped: CoroutineScopeScoped // (1)!
}
fun createAppScope(appComponent: AppComponent): Scope {
return Scope.buildRootScope {
addKotlinInjectComponent(appComponent)
addCoroutineScopeScoped(appComponent.appScopeCoroutineScopeScoped)
}
}
```
1. `CoroutineScopeScoped` wraps a `CoroutineScope` in a `Scoped` instance. In `onExitScope()` of this instance the
`CoroutineScope` will be canceled.
The `CoroutineScope` can be injected in classes and used to launch async work. A common pattern is to use the
`onEnterScope()` function when implementing the `Scoped` interface to launch coroutine jobs:
```kotlin
override fun onEnterScope(scope: Scope) {
// This job will be automatically canceled when the `scope` gets destroyed.
scope.launch { // (1)!
someFlow.collect {
...
}
}
}
```
1. `scope.launch` is a convenience function for `scope.coroutineScope().launch`.
Since the `CoroutineScope` is part of the Metro or `kotlin-inject-anvil` object graph, the `CoroutineScope` can be
injected in the constructor as well:
```kotlin
@Inject
@SingleIn(AppScope::class)
class MyClass(@ForScope(AppScope::class) coroutineScope: CoroutineScope) {
init {
coroutineScope.launch {
...
}
}
}
```
Whenever a `CoroutineScope` is injected, a new child `CoroutineScope` with its own `Job` is created (the parent `Job`
points to the shared `CoroutineScope` `Job`). The prevents consumers from accidentally tearing down all running
coroutines when canceling an injected `CoroutineScope`.
```kotlin
override fun onEnterScope(scope: Scope) {
val myCoroutineScope = scope.coroutineScope()
myCoroutineScope.launch { ... }
myCoroutineScope.launch { ... }
// This is safe to do and only cancels the two launched jobs and `myCoroutineScope`. It doesn't cancel the
// shared `CoroutineScope` hosted within the `scope` object.
myCoroutineScope.cancel()
}
```
## `Scoped`
Service objects can tie themselves to the lifecycle of a scope by implementing the
[`Scoped`](https://github.com/amzn/app-platform/blob/main/scope/public/src/commonMain/kotlin/software/amazon/app/platform/scope/Scoped.kt)
interface:
```kotlin
interface Scoped {
fun onEnterScope(scope: Scope)
fun onExitScope()
}
```
Usually, we rely on our dependency injection framework to instantiate all `Scoped` instances for a scope. By doing
so service objects will be automatically created when their corresponding scope is created and receive a callback
when their scope is destroyed. This helps with loose coupling between our service objects. Implementing the `Scoped`
interface is a detail, which doesn’t need to be exposed to the API layer:
```kotlin hl_lines="5 6 7"
interface LocationProvider {
val location: StateFlow
}
class AndroidLocationProvider(
private val locationManager: LocationManager
) : LocationProvider, Scoped {
private val _location = MutableStateFlow()
override val location get() = _location
override fun onEnterScope(scope: Scope) {
scope.launch {
// Observe location updates through LocationManager
val androidLocation = ...
_location.value = androidLocation
}
}
}
```
!!! note
Note in the example that the concrete implementation class implements the `Scoped` interface and
not `LocationProvider`. Being lifecycle aware is an implementation detail.
How the `Scoped` object is instantiated depends on the dependency injection framework and which scope to use.
With Metro, or alternatively `kotlin-inject-anvil`, for the app scope it would be:
=== "Metro"
```kotlin
@Inject // (1)!
@SingleIn(AppScope::class) // (2)!
@ContributesScoped(AppScope::class) //(3)!
class AndroidLocationProvider(
...
) : LocationProvider, Scoped {
...
}
```
1. This annotation is required to support constructor injection.
2. This annotation ensures that there is only ever a single instance of `AndroidLocationProvider` in the `AppScope`.
3. This annotation ensures that when somebody injects `LocationProvider`, then they get the singleton instance of `AndroidLocationProvider`.
??? note "`@ContributesScoped` will generate and contribute bindings"
The `@ContributesScoped` annotation will generate a graph interface with bindings for `LocationProvider`
and `Scoped`. The generated interface will be added automatically to the `AppScope`. No further manual step
is needed.
```kotlin
@Binds
val AndroidLocationProvider.binds: LocationProvider
@Binds @IntoSet @ForScope(AppScope::class)
val AndroidLocationProvider.bindsScoped: Scoped
```
=== "kotlin-inject-anvil"
```kotlin
@Inject // (1)!
@SingleIn(AppScope::class) // (2)!
@ContributesBinding(AppScope::class) //(3)!
class AndroidLocationProvider(
...
) : LocationProvider, Scoped {
...
}
```
1. This annotation is required to support constructor injection.
2. This annotation ensures that there is only ever a single instance of `AndroidLocationProvider` in the `AppScope`.
3. This annotation ensures that when somebody injects `LocationProvider`, then they get the singleton instance of `AndroidLocationProvider`.
??? note "`@ContributesBinding` will generate and contribute bindings"
The `@ContributesBinding` annotation will generate a component interface with bindings for `LocationProvider`
and `Scoped`. The generated interface will be added automatically to the `AppScope`. No further manual step
is needed.
```kotlin
@Provides
public fun provideAndroidLocationProvider(androidLocationProvider: AndroidLocationProvider): LocationProvider = androidLocationProvider
@Provides
@IntoSet
@ForScope(AppScope::class)
fun provideAndroidLocationProviderScoped(androidLocationProvider: AndroidLocationProvider): Scoped = androidLocationProvider
```
??? example "Sample"
Another example in the sample app is [`SessionTimeout`](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/SessionTimeout.kt).
This class is part of the `UserScope` and implements the `Scoped` interface. `onEnterScope()` will be called when
the user logs in and `onExitScope()` when the user logs out.
```kotlin
@Inject
@SingleIn(UserScope::class)
@ContributesScoped(UserScope::class) // Use @ContributesBinding with kotlin-inject-anvil.
class SessionTimeout(...) : Scoped {
override fun onEnterScope(scope: Scope) {
// This job will be automatically canceled when the user logs out and the user scope is
// destroyed.
scope.launch {
while (userManager.user.value != null) {
...
}
}
scope.launch {
...
}
}
}
```
### Registering `Scoped`
The dependency injection frameworks like Metro and `kotlin-inject-anvil` are only responsible for creating `Scoped`
instances, but don't automatically register them in the `Scope`. This has to be done whenever the `Scope` is created:
=== "Metro"
```kotlin hl_lines="4 16"
@DependencyGraph(AppScope::class)
interface AppGraph {
/** All [Scoped] instances part of the app scope. */
@ForScope(AppScope::class) val appScopedInstances: Set
}
fun createAppScope(appGraph: AppGraph): Scope {
val rootScope =
Scope.buildRootScope {
addMetroDependencyGraph(appGraph)
addCoroutineScopeScoped(appGraph.appScopeCoroutineScopeScoped)
}
rootScope.register(appGraph.appScopedInstances)
return rootScope
}
```
By calling `appGraph.appScopedInstances` the DI framework instantiates all `Scoped` instances part of the
`AppScope`. The `rootScope.register(...)` call will register all of the `Scoped` instances and invoke
`onEnterScope(scope)`. When calling `rootScope.destroy()` later at some point, then `onExitScope()` will be
called for all `Scoped` instances.
=== "kotlin-inject-anvil"
```kotlin hl_lines="5 16"
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
interface AppComponent {
/** All [Scoped] instances part of the app scope. */
@ForScope(AppScope::class) val appScopedInstances: Set
}
fun createAppScope(appComponent: AppComponent): Scope {
val rootScope =
Scope.buildRootScope {
addKotlinInjectComponent(appComponent)
addCoroutineScopeScoped(appComponent.appScopeCoroutineScopeScoped)
}
rootScope.register(appComponent.appScopedInstances)
return rootScope
}
```
By calling `appComponent.appScopedInstances` the DI framework instantiates all `Scoped` instances part of the
`AppScope`. The `rootScope.register(...)` call will register all of the `Scoped` instances and invoke
`onEnterScope(scope)`. When calling `rootScope.destroy()` later at some point, then `onExitScope()` will be
called for all `Scoped` instances.
??? example "Sample"
The sample application implements this mechanism for the
[`AppScope`](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/commonMain/kotlin/software/amazon/app/platform/sample/DemoApplication.kt#L31-L33)
and the [`UserScope`](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserManagerImpl.kt#L58-L60).
### `onExit`
The convenience function `onExit` is handy when you want to create objects lazily within `onEnterScope()` and
not create a property in the class itself. This callback notifies you when the `Scope` is destroyed similar to
`onExitScope()`.
=== "Metro"
```kotlin
@Inject
@SingleIn(AppScope::class)
@ContributesScoped(AppScope::class)
class MyClass(private val application: Application) : Scoped {
override fun onEnterScope(scope: Scope) {
val receiver = object : BroadcastReceiver()
application.registerReceiver(receiver, Intent())
scope.onExit {
// This function is invoked when the scope gets destroyed.
application.unregisterReceiver(receiver)
}
}
}
```
=== "kotlin-inject-anvil"
```kotlin
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class MyClass(private val application: Application) : Scoped {
override fun onEnterScope(scope: Scope) {
val receiver = object : BroadcastReceiver()
application.registerReceiver(receiver, Intent())
scope.onExit {
// This function is invoked when the scope gets destroyed.
application.unregisterReceiver(receiver)
}
}
}
```
### Threading
Which thread is used for calling `onEnterScope()` and `onExitScope()` is an implementation detail of the scope
owner when calling `scope.register(Scoped)`. Usually, the app scope is created as soon as possible when the
application launches and therefore the main thread is used. Child scopes may use the main thread or a background
thread.
To safely launch long running work or blocking tasks it’s recommended to use the coroutine scope provided by the
`Scope`:
```kotlin
override fun onEnterScope(scope: Scope) {
scope.launch { ... }
}
```
Clean up routines in `onExitScope()` must be blocking, otherwise these tasks live longer than the `Scope` and
therefore may cause a leak (thread and memory) and potential race conditions. It’s strongly recommended not to
launch any asynchronous work within `onExitScope()`. By the time `onExitScope()` is called, the coroutine
scope provided by the `Scope` has been canceled already.
## Hosting `Scopes`
Scopes need to be remembered and must be accessible in order to get access to their services. Where to host scopes
depends on what scopes are required and when they need to be created. Most apps have some form of an application
scope, which is a singleton scope for the entire lifetime of the application. A natural place to host this scope
for Android apps is within the `Application` class, for iOS apps within `App` struct or the main function
for desktop applications.
A user scope has a shorter lifecycle than the application scope, but usually lives longer than UI components.
It is commonly hosted by a service object managing the login state. This scope is destroyed after the user
session expires.
App Platform by default only provides the `AppScope`, which has to be manually created by each application as
highlighted above.
??? example "Sample"
The sample application has a common class [DemoApplication](https://github.com/amzn/app-platform/blob/main/sample/app/src/commonMain/kotlin/software/amazon/app/platform/sample/DemoApplication.kt)
that is responsible for creating the app scope. The Android app instantiates `DemoApplication` in the
[`Application` class](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/androidMain/kotlin/software/amazon/app/platform/sample/AndroidApplication.kt#L19).
The iOS sample creates the `DemoApplication` in the [`UIApplicationDelegate`](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/iosApp/iosApp/iOSApp.swift#L6).
On Desktop `DemoApplication` is created part of the [`main()` function](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/desktopMain/kotlin/software/amazon/app/platform/sample/Main.kt#L8).
### `RootScopeProvider`
[`RootScopeProvider`](https://github.com/amzn/app-platform/blob/main/scope/public/src/commonMain/kotlin/software/amazon/app/platform/scope/RootScopeProvider.kt),
as the name suggests, gives access to the root `Scope` ("AppScope"). Usually, this interface is implemented by the application
object of the individual platform to get access to the root `Scope` from a platform context, e.g. on Android this is
handy in an `Activity`:
```kotlin
class MainActivity : Activity() {
private val rootScopeProvider
get() = application as RootScopeProvider
...
}
```
??? example "Sample"
The sample application implements `RootScopeProvider` in the Android
[`Application` class](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/app/src/androidMain/kotlin/software/amazon/app/platform/sample/AndroidApplication.kt#L19)
and the iOS [`UIApplicationDelegate`](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/iosApp/iosApp/iOSApp.swift#L6).
On Desktop there is no concept of a singleton application object by default, but in the sample app we created an
equivalent with [`DesktopApp`](https://github.com/amzn/app-platform/blob/main/sample/app/src/desktopMain/kotlin/software/amazon/app/platform/sample/DesktopApp.kt).
================================================
FILE: docs/setup.md
================================================
# Setup
## Gradle
App Platform, its various features and dependencies are all configured through a Gradle plugin. The various options
are explained in more detail in many of the following sections.
=== "build.gradle"
```groovy
plugins {
id 'software.amazon.app.platform' version 'x.y.z'
}
appPlatform {
// false by default. Adds dependencies on the APIs for scopes, presenters and renderers in order to use the App Platform.
addPublicModuleDependencies true
// false by default. Helpful for final application modules that must consume concrete implementations and not only APIs.
addImplModuleDependencies true
// false by default. Recommended DI option. Configures Metro and adds App Platform specific extensions as dependency.
enableMetro true
// false by default. Alternative DI option. Configures KSP and adds the kotlin-inject-anvil library as dependency.
enableKotlinInject true
// false by default. Configures Molecule and provides access to the MoleculePresenter API.
enableMoleculePresenters true
// false by default. Adds the necessary dependencies to use Compose Multiplatform with Renderers.
enableComposeUi true
// false by default. Verifies that this module follows conventions for our module structure and
// adds default dependencies. For Android projects it sets the namespace to avoid conflicts.
enableModuleStructure true
}
```
=== "build.gradle.kts"
```kotlin
plugins {
id("software.amazon.app.platform") version "x.y.z"
}
appPlatform {
// false by default. Adds dependencies on the APIs for scopes, presenters and renderers in order to use the App Platform.
addPublicModuleDependencies(true)
// false by default. Helpful for final application modules that must consume concrete implementations and not only APIs.
addImplModuleDependencies(true)
// false by default. Recommended DI option. Configures Metro and adds App Platform specific extensions as dependency.
enableMetro(true)
// false by default. Alternative DI option. Configures KSP and adds the kotlin-inject-anvil library as dependency.
enableKotlinInject(true)
// false by default. Configures Molecule and provides access to the MoleculePresenter API.
enableMoleculePresenters(true)
// false by default. Adds the necessary dependencies to use Compose Multiplatform with Renderers.
enableComposeUi(true)
// false by default. Verifies that this module follows conventions for our module structure and
// adds default dependencies. For Android projects it sets the namespace to avoid conflicts.
enableModuleStructure(true)
}
```
!!! note
All settings of App Platform are optional and opt-in, e.g. you can use Molecule Presenters without enabling
the opinionated module structure. Compose UI can be enabled without using `Metro` or
`kotlin-inject-anvil`. When you do want DI, Metro is the recommended default.
## Snapshot
To import snapshot builds use following repository:
=== "build.gradle"
```groovy
maven {
url = 'https://central.sonatype.com/repository/maven-snapshots/'
}
```
=== "build.gradle.kts"
```kotlin
maven {
url = uri("https://central.sonatype.com/repository/maven-snapshots/")
}
```
================================================
FILE: docs/template.md
================================================
# Template
[`Templates`](https://github.com/amzn/app-platform/blob/main/presenter/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/template/Template.kt)
are an abstraction between `Presenters` and `Renderers` and represent the root of the presenter and renderer tree.
Practically, a template is one particular type of `BaseModel` that hosts other models (a container of models).
However, instead of using a weak type like `List`, a template carries semantics about what content should
be rendered, how many UI layers there are and where each individual model should be displayed.
`Templates` are app specific and not shared, because each app may use a different layering mechanism for individual
screen configurations. An example template definition could look like this:
```kotlin
sealed interface SampleAppTemplate : Template {
data class FullScreenTemplate(
val model: BaseModel,
) : SampleAppTemplate
data class ListDetailTemplate(
val list: BaseModel,
val detail: BaseModel,
) : SampleAppTemplate
}
```
??? example "Sample"
A [similar hierarchy](https://github.com/amzn/app-platform/blob/main/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/SampleAppTemplate.kt)
is implemented in the sample application.
The `Template` interface extends `BaseModel` and each app must come with its own `TemplatePresenter` and
`TemplateRenderer`. Both are implemented the same way as other presenters and renderers would be implemented.
The responsibility of the `TemplatePresenter` is to wrap another presenter and wrap its models within a `Template`,
e.g.
```kotlin hl_lines="8"
@Inject
class SampleAppTemplatePresenter(
@Assisted private val rootPresenter: MoleculePresenter,
) : MoleculePresenter {
@Composable
override fun present(input: Unit): SampleAppTemplate {
return returningCompositionLocalProvider {
rootPresenter.present(Unit).toTemplate {
SampleAppTemplate.FullScreenTemplate(it)
}
}
}
}
```
??? example "Sample"
The sample app has a [similar implementation](https://github.com/amzn/app-platform/blob/main/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/SampleAppTemplatePresenter.kt).
The wrapped presenter can override which `Template` to use by implementing [`ModelDelegate`](https://github.com/amzn/app-platform/blob/main/presenter/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/template/ModelDelegate.kt),
e.g.
```kotlin
data class Model(
...
) : BaseModel, ModelDelegate {
override fun delegate(): BaseModel = ListDetailTemplate(...)
}
```
??? example "Sample"
The sample app makes use of this mechanism in the [user page](https://github.com/amzn/app-platform/blob/main/sample/user/public/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPagePresenter.kt),
where it the layout is split between a list presenter / renderer and detail presenter / renderer.
```kotlin
data class Model(
val listModel: BaseModel,
val detailModel: BaseModel
) : BaseModel, ModelDelegate {
override fun delegate(): BaseModel {
return SampleAppTemplate.ListDetailTemplate(listModel, detailModel)
}
}
```
The `TemplateRenderer` receives the specific `Template`, lays out necessary containers and renders individual
models in these layers. The renderer often injects `RendererFactory` to create renderers for the models, e.g.
```kotlin
@Inject
@ContributesRenderer
class ComposeSampleAppTemplateRenderer(
private val rendererFactory: RendererFactory
) : ComposeRenderer() {
@Composable
override fun Compose(model: SampleAppTemplate) {
when (model) {
is SampleAppTemplate.FullScreenTemplate -> FullScreen(model)
is SampleAppTemplate.ListDetailTemplate -> ListDetail(model)
}
}
@Composable
private fun FullScreen(template: SampleAppTemplate.FullScreenTemplate) {
val renderer = rendererFactory.getComposeRenderer(template.model)
renderer.renderCompose(template.model)
}
@Composable
private fun ListDetail(template: SampleAppTemplate.ListDetailTemplate) {
Row {
Column {
rendererFactory.getComposeRenderer(template.list).renderCompose(template.list)
}
Column {
rendererFactory.getComposeRenderer(template.detail).renderCompose(template.detail)
}
}
}
}
```
### Consuming `Templates`
On the API level `Templates` are regular `Models`, with a regular `Presenter` and `Renderer`. Therefore, they
require no special treatment and the regular `RendererFactory` can be used:
```kotlin
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
ComposeUIViewController {
val factory = remember { ComposeRendererFactory(rootScopeProvider = rootScopeProvider) }
val templatePresenter = remember {
val component = rootScopeProvider.rootScope.kotlinInjectComponent()
component.factory.createSampleAppTemplatePresenter(component.navigationPresenter)
}
val template = templatePresenter.present(Unit)
factory.getComposeRenderer(template).renderCompose(template)
}
@ContributesTo(AppScope::class)
interface ViewControllerComponent {
val factory: SampleAppTemplatePresenter.Factory
val navigationPresenter: NavigationPresenter
}
```
## Unidirectional dataflow
Templates complete the circle in our unidirectional dataflow pattern:
{ width="600" }
This diagram summarizes how models from child presenters bubble up ultimately to the template presenter. The template
presenter wraps the models in a template, which is then handed off the rendering pipeline. `RendererFactory` finds
the right renderers for the template and models and the content will be shown on screen by individual renderers. The
circle repeats either when a renderer invokes a callback from the model and sends the event back to the presenter or
another state change occurs within the the presenter tree.
================================================
FILE: docs/testing.md
================================================
# Testing
A fundamental design pattern to make testing effective is dependency inversion, which means that high-level
APIs don’t depend on low-level details and low-level details only import other high-level APIs.
It significantly reduces coupling between components.
App Platform implements the pattern in its [module structure](module-structure.md#gradle-modules) and in
[Kotlin code](module-structure.md#kotlin-code). By relying on dependency inversion, we decouple projects from
their dependencies and enable testing in isolation. This approach is important for unit tests, instrumented tested
and integration tests. These three types of tests rely on a chain of trust, where we assume that dependencies
are functioning and tests don’t need to be repeated.
{ width="400" }
!!! info "Instrumented tests"
The sample application implements instrumented tests for two screens and navigates between the tests. The
[tests for Desktop](https://github.com/amzn/app-platform/blob/main/sample/app/src/desktopTest/kotlin/software/amazon/app/platform/sample/LoginUiTest.kt)
highlight how templates are rendered and robots are used for verification. They also set up a Metro
[`TestDesktopAppGraph`](https://github.com/amzn/app-platform/blob/main/sample/app/src/desktopTest/kotlin/software/amazon/app/platform/sample/TestDesktopAppGraph.kt),
which replaces the main desktop graph.
The same UI test is [implemented for Android](https://github.com/amzn/app-platform/blob/main/sample/app/src/androidInstrumentedTest/kotlin/software/amazon/app/platform/sample/AndroidLoginUiTest.kt).
The Android tests reuse the same robots for verification and set up a
[`TestAndroidAppGraph`](https://github.com/amzn/app-platform/blob/main/sample/app/src/androidInstrumentedTest/kotlin/software/amazon/app/platform/sample/TestAndroidAppGraph.kt)
in a similar way. The sample now uses Metro throughout, while `kotlin-inject-anvil` remains
available as the alternative path.
## Fakes
Unit tests build the foundation of the testing pyramid. They verify the smallest components of our app, which
usually are single classes or functions and we rarely test multiple classes in combination. Dependencies of
these classes are typically replaced by fakes. Due to this low coupling unit tests tend to be very stable.
!!! info "Fakes vs real implementations"
Using real implementations of dependencies for the unit under test is a valid option as it brings the
tested code close to production, increases confidence and removes isolation.
A [best practice from Google](https://abseil.io/resources/swe-book/html/ch13.html) is summarized as:
> A real implementation is preferred if it is fast, deterministic, and has simple dependencies. For example,
> a real implementation should be used for a value object. Examples include an amount of money, a date, a
> geographical address, or a collection class such as a list or a map.
>
> However, for more complex code, using a real implementation often isn’t feasible. There might not be an
> exact answer on when to use a real implementation or a test double given that there are trade-offs to be made.
The trade-offs include execution time, determinism and dependency construction. Fakes improve all three points
by avoiding slow IO, returning stable results and breaking dependency chains at the cost of diverging from the
behavior in production and reduced confidence.
```kotlin
interface LocationProvider {
val location: StateFlow
}
class RoutingRepository(
private val locationProvider: LocationProvider
)
```
Imagine to test `RoutingRepository`. To create an new instance under test, we must provide a `LocationProvider`.
Since we use dependency inversion and didn’t hardcode a concrete implementation, it is simple to implement a fake
for this interface:
```kotlin
class FakeLocationProvider(
val currentLocation: Location = Location(..)
) : LocationProvider {
private val _location = MutableStateFlow(currentLocation)
override val location = _location
fun updateLocation(newLocation: Location) {
_location.value = newLocation
}
}
```
Now we can instantiate our `RoutingRepository`:
```kotlin
@Test
fun `the route is updated when the driver doesn't follow directions`() {
val locationProvider = FakeLocationProvider()
val routingRepository = RoutingRepository(locationProvider)
locationProvider.updateLocation(...)
}
```
Good fake implementations are valuable. It’s best practice and strongly encouraged as an API provider to implement
fakes for APIs and share them with consumers. The [App Platform module structure](module-structure.md) provides
[`:testing` modules](module-structure.md#testing) for this purpose. For example, the owner of `LocationProvider`
is encouraged to use this structure:
```
:location-provider:public src/commonMain/kotlin/.../LocationProvider.kt
:location-provider:testing src/commonMain/kotlin/.../FakeLocationProvider.kt
```
The owner of `RoutingRepository` can import `:location-provider:testing` and reuse the provided fake in tests.
This avoids duplication.
??? example "Sample"
The sample app uses `:testing` modules to implement and share fakes across modules, e.g.
[`:sample:user:testing`](https://github.com/amzn/app-platform/tree/main/sample/user/testing). In other modules
fakes are created next to the tests ad-hoc, e.g. [`FakeUserPagePresenter`](https://github.com/amzn/app-platform/blob/0f3e242ae08bb242fbd7080d33caa069c8fae2b4/sample/navigation/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/navigation/NavigationPresenterImplTest.kt#L51-L58)
and [`FakeAnimationHelper`](https://github.com/amzn/app-platform/blob/main/sample/user/impl/src/commonTest/kotlin/software/amazon/app/platform/sample/user/FakeAnimationHelper.kt).
```kotlin
private class FakeUserPagePresenter : UserPagePresenter {
@Composable
override fun present(input: Unit): UserPagePresenter.Model =
UserPagePresenter.Model(
listModel = object : BaseModel {},
detailModel = object : BaseModel {},
)
}
```
```kotlin
object FakeAnimationHelper : AnimationHelper {
override fun isAnimationsEnabled(): Boolean = true
}
```
## Robots
Test [`Robots`](https://jakewharton.com/testing-robots/) are an abstraction between test interactions and the
underlying implementation. Imagine several tests clicking the *Logout* button use the label to find the UI element
on the screen. If the copy changes from *Logout* to *Sign out*, then all these tests would need to be updated.
That is tedious and makes tests harder to maintain. A test robot would hide how the *Logout* button can be found
on screen and only provides an option for the necessary interaction:
```kotlin
class LogoutRobot : Robot {
fun clickLogoutButton() { .. }
}
```
Test [`Robots`](https://github.com/amzn/app-platform/blob/main/robot/public/src/commonMain/kotlin/software/amazon/app/platform/robot/Robot.kt)
are not limited to UI interactions such as verifying UI elements are shown or hidden and invoking
actions on them. They can also be used to change fake implementations or make assertions on them. Imagine a
robot toggling network connectivity. Tests do not interact with fake implementations directly similar to them
not interacting with UI elements directly.
```kotlin
class NetworkRobot : Robot {
var networkEnabled: Boolean
var connectivity: Connectivity
var throwErrorOnSendingRequest: Boolean = false
enum class Connectivity {
LTE, 3G, WIFI, ...
}
}
```
Another use case is verifying metrics and analytics events. In instrumented tests we’d use a fake metrics
implementation rather than sending events to our backend system. The robot would interact with the fake
implementation and make assertions:
```kotlin
class FakeMetricsService : MetricsService {
val metrics: List
}
class MetricsRobot : Robot {
private val service: FakeMetricsService ...
fun assertMetricTracked(metric: Metric) {
assertThat(service.metrics).contains(metric)
}
}
```
Fake implementations and test robots help verifying interactions with hardware or devices that are not available
during an instrumented test run. For example, interactions with other devices can be simulated using a fake
connection.
```kotlin
interface WebSocketConnection {
suspend fun send(message: ByteArray)
}
class FakeWebSocketConnection : WebSocketConnection {
var throwError: Boolean
override suspend fun send(message: ByteArray) {
if (throwError) {
throw Exception("..."
} else {
trackMessage(message)
}
}
}
class ConnectionRobot : Robot {
private val webSocketConnection: FakeWebSocketConnection
fun sendingMessageFails() {
webSocketConnection.throwError = true
}
fun sendingMessageSucceeds() {
webSocketConnection.throwError = false
}
}
```
### Robot types
[`Robot`](https://github.com/amzn/app-platform/blob/main/robot/public/src/commonMain/kotlin/software/amazon/app/platform/robot/Robot.kt).
: Use this common interface for robots that don't interact with any UI, whether that's Compose Multiplatform or
Android Views. To obtain an instance of such a robot use the `robot()` function:
```kotlin
@Inject
@ContributesRobot(AppScope::class)
class MetricsRobot(
private val metricsService: FakeMetricsService
) : Robot {
fun assertMetricTracked(metric: Metric) {
assertThat(metricsService.metrics).contains(metric)
}
}
@Test
fun verify_analytics_event_tracked() {
...
robot().assertMetricTracked(..)
}
```
[`ComposeRobot`](https://github.com/amzn/app-platform/blob/main/robot-compose-multiplatform/public/src/commonMain/kotlin/software/amazon/app/platform/robot/ComposeRobot.kt)
: `ComposeRobot` should be used as parent type when the robot interacts with Compose UI elements. These robots need
access to a `SemanticsNodeInteractionsProvider` instance, which is for example provided by calling
`runComposeUiTest { ... }` within a test. To forward the `SemanticsNodeInteractionsProvider` instance to the robot
call `composeRobot()` instead of `robot()`.
!!! warning
Calling `robot()` for a `ComposeRobot` will result in a crash. Always use `composeRobot()` instead.
```kotlin
@ContributesRobot(AppScope::class)
class LoginRobot : ComposeRobot() {
private val loginButtonNode
get() = compose.onNodeWithTag("loginButton")
/** Verify that login button is displayed. */
fun seeLoginButton() {
loginButtonNode.assertIsDisplayed()
}
/** Clicks the login button and starts the login process. */
fun clickLoginButton() {
loginButtonNode.performClick()
}
}
@Test
fun `sample test`() {
runComposeUiTest {
composeRobot {
seeLoginButton()
clickLoginButton()
}
}
}
```
[`AndroidViewRobot`](https://github.com/amzn/app-platform/blob/main/robot/public/src/androidMain/kotlin/software/amazon/app/platform/robot/AndroidViewRobot.kt)
: `AndroidViewRobot` should be used as parent type when the robot interacts with Android Views.
To obtain an instance of such a robot use the `robot()` function:
```kotlin
@ContributesRobot(AppScope::class)
class AndroidCounterRobot : AndroidViewRobot() {
fun seeCounterView() {
onView(withText(containsString("Counter: "))).check(matches(isDisplayed()))
}
}
@Test
fun counter_is_shown() {
robot {
seeCounterView()
}
}
```
`Robots` must be annotated with `@ContributesRobot` in order to find them during tests when using the `robot()`
or `composeRobot()` function. The annotation makes sure that the robots are added to the Metro
or `kotlin-inject-anvil` dependency graph.
??? info "Generated code"
The `@ContributesRobot` annotation generates following code.
=== "Metro"
```kotlin
@ContributesTo(AppScope::class)
public interface LoginRobotGraph {
@Provides public fun provideLoginRobot(): LoginRobot = LoginRobot()
@Provides
@IntoMap
@RobotKey(LoginRobot::class)
public fun provideLoginRobotIntoMap(
robot: Provider
): Robot = robot()
}
```
=== "kotlin-inject-anvil"
```kotlin
@ContributesTo(AppScope::class)
public interface LoginRobotComponent {
@Provides public fun provideLoginRobot(): LoginRobot = LoginRobot()
@Provides
@IntoMap
public fun provideLoginRobotIntoMap(
robot: () -> LoginRobot
): Pair, () -> Robot> = LoginRobot::class to robot
}
```
If a `Robot` needs to inject other types such a fake implementations, then it needs to be additionally annotated with
`@Inject`, e.g.
```kotlin
@Inject
@ContributesRobot(AppScope::class)
class MetricsRobot(
private val metricsService: FakeMetricsService
) : Robot {
fun assertMetricTracked(metric: Metric) {
assertThat(metricsService.metrics).contains(metric)
}
}
```
### `:*-robots` modules
Similar to sharing fakes for unit tests by leveraging `:testing` modules, the module structure of App Platform
provides [`:*-robots` modules](module-structure.md#robots) to share code for instrumented tests across projects.
It’s strongly encouraged for features to create `:*-robots` modules and share robot implementations.
??? example "Sample"
The sample application comes with two robot implementations [`LoginRobot`](https://github.com/amzn/app-platform/blob/main/sample/login/impl-robots/src/commonMain/kotlin/software/amazon/app/platform/sample/login/LoginRobot.kt)
and [`UserPageRobot`](https://github.com/amzn/app-platform/blob/main/sample/user/impl-robots/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageRobot.kt),
each living in its feature specific `:robots` module.
## Mocks
**Which mocking framework is recommended?**
None.
Mocking frameworks in general are discouraged and the downside outweigh the little conveniences they offer.
By following the principle of dependency inversion we can easily avoid using mocking frameworks and implement
fakes instead. There are many good resources available describing the advantages of fakes over mocking framework.
We recommend reading the provided resources in-order:
* [AndroidX](https://github.com/androidx/androidx/blob/acb603e0857476b17e605fd1384c1f45e7991665/docs/api_guidelines/testing.md) strongly discourages mocking frameworks and banned them from new code. This guide explains in more detail their reasoning and it resonates well.
* [Google engineers](https://abseil.io/resources/swe-book/html/ch13.html) compare test doubles and give excellent advice for how to fake dependencies (this article is longer, but it’s likely the best one available).
* [developer.android.com](https://developer.android.com/training/testing/fundamentals/test-doubles#types) prefers fakes over mocks for test doubles: “Fakes don't require a mocking framework and are lightweight. They are preferred.”
* [CashApp](https://www.billjings.net/posts/title/fakes-are-great-but-mocks-i-hate/?up=technical) banned mocking frameworks in the Android codebase, because mocks are a maintenance burden.
* [Ryan Harter](https://ryanharter.com/blog/2020/06/replacing-mocks/) calls out easy traps when using mocks.
* [Pravin Sonawane](https://medium.com/@june.pravin/mocking-is-not-practical-use-fakes-e30cc6eaaf4e) makes similar arguments and highlights how mocks encourage testing the “how” rather than focusing on the “what” (inputs and outputs).
* Google blog [Don’t overuse mocks](https://testing.googleblog.com/2013/05/testing-on-toilet-dont-overuse-mocks.html) highlights some downsides of mocks and presents real or fake implementations as alternative.
================================================
FILE: gradle/detekt-config.yml
================================================
# Detekt configuration tweaks. These are documented at
# https://detekt.github.io/detekt/configurations.html
# https://detekt.github.io/detekt/comments.html
# Also helpful are the Detekt default settings at
# https://github.com/detekt/detekt/blob/main/detekt-core/src/main/resources/default-detekt-config.yml
style:
DataClassShouldBeImmutable:
active: true
MagicNumber:
# Magic numbers in enums should be ignored
ignoreEnums: true
# The next two parameters are recommended for Compose: https://detekt.dev/docs/introduction/compose/
ignorePropertyDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
UnusedPrivateMember:
# Recommended for Compose: https://detekt.dev/docs/introduction/compose/
ignoreAnnotated: ['Preview']
MaxLineLength:
active: false
comments:
DeprecatedBlockTag:
active: true
EndOfSentenceFormat:
active: true
UndocumentedPublicClass:
active: true
ignoreDefaultCompanionObject: true
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
UndocumentedPublicFunction:
active: true
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
UndocumentedPublicProperty:
active: true
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
complexity:
LongParameterList:
constructorThreshold: 12
ignoreAnnotated: [
‘Inject’,
]
# The next two parameters are recommended for Compose: https://detekt.dev/docs/introduction/compose/
functionThreshold: 12
ignoreDefaultParameters: true
TooManyFunctions:
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
ignoreDeprecated: true
ignorePrivate: true
ignoreOverridden: true
LargeClass:
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
LongMethod:
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
naming:
InvalidPackageDeclaration:
active: true
FunctionNaming:
# The next two parameters are recommended for Compose: https://detekt.dev/docs/introduction/compose/
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
ignoreAnnotated: ['Composable']
excludes: [
'**/src/*Test/**/*.kt',
'**/src/test/**/*.kt',
]
================================================
FILE: gradle/libs.versions.toml
================================================
[versions]
agp = "8.13.2"
android-compileSdk = "36"
# https://developer.android.com/jetpack/androidx/releases/compose-ui
# https://maven.google.com/web/index.html#androidx.compose.ui:ui
android-compose-version = "1.10.6"
android-minSdk = "23"
android-targetSdk = "36"
#noinspection GradleDependency
androidx-activity = "1.13.0"
androidx-annotations = "1.9.1"
androidx-collection = "1.5.0"
#noinspection GradleDependency
androidx-constraintlayout = "2.1.4"
#noinspection GradleDependency
androidx-core = "1.10.1"
androidx-lint-gradle = "1.0.0-alpha06"
androidx-test-espresso = "3.7.0"
androidx-test-junit = "1.3.0"
androidx-test-monitor = "1.8.0"
androidx-test-orchestrator = "1.6.1"
androidx-test-rules = "1.7.0"
androidx-test-runner = "1.7.0"
assertk = "0.28.1"
auto-service = "1.1.1"
auto-service-ksp = "1.2.0"
build-config = "6.0.9"
# https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility
# https://mvnrepository.com/artifact/org.jetbrains.compose/compose-gradle-plugin
# https://github.com/JetBrains/compose-multiplatform/releases
compose-multiplatform = "1.10.3"
coroutines = "1.10.2"
detekt = "1.23.8"
graphviz-java = "0.18.1"
jvm-compatibility = "11"
# We need a newer version for buildSrc. This project uses JDK 21, Metro requires JDK 21, and therefore we should
# compile buildSrc with 21.
jvm-buildsrc = "21"
kotlin = "2.3.20"
kotlin-atomicfu = "0.32.1"
kotlin-compile-testing = "0.12.1"
kotlin-hierarchy = "1.1"
kotlin-inject = "0.9.0"
kotlin-inject-anvil = "0.1.7"
kotlin-poet = "2.3.0"
kotlinx-binaryCompatibilityValidator = "0.18.1"
ktfmt-gradle = "0.26.0"
ksp = "2.3.6"
maven-publish = "0.36.0"
metro = "1.0.0-RC2"
molecule = "2.2.0"
navigation-event = "1.0.1"
navigation3 = "1.1.0"
#noinspection GradleDependency
recyclerView = "1.2.1"
turbine = "1.2.1"
#noinspection GradleDependency
viewbinding = "7.0.0"
[libraries]
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
android-gradle-plugin-api = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-annotations = { module = "androidx.annotation:annotation", version.ref = "androidx-annotations" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-collection = { module = "androidx.collection:collection", version.ref = "androidx-collection" }
androidx-lint-gradle = { module = "androidx.lint:lint-gradle", version.ref = "androidx-lint-gradle" }
androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidx-test-monitor" }
androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "auto-service-ksp" }
build-config-gradle-plugin = { module = "com.github.gmazzo.buildconfig:plugin", version.ref = "build-config" }
compose-gradle-plugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" }
compose-ui-back-handler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "android-compose-version" }
compose-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android", version.ref = "android-compose-version" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "android-compose-version" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
detekt-gradle-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
graphviz-java = { module = "guru.nidi:graphviz-java", version.ref = "graphviz-java" }
kotlin-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlin-atomicfu" }
kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
kotlin-annotations-jvm = { module = "org.jetbrains.kotlin:kotlin-annotations-jvm", version.ref = "kotlin" }
kotlin-compiler = { module = "org.jetbrains.kotlin:kotlin-compiler", version.ref = "kotlin" }
kotlin-compile-testing-core = { module = "dev.zacsweers.kctfork:core", version.ref = "kotlin-compile-testing" }
kotlin-compile-testing-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kotlin-compile-testing" }
kotlin-compiler-embeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" }
kotlin-compiler-internal-test-framework = { module = "org.jetbrains.kotlin:kotlin-compiler-internal-test-framework", version.ref = "kotlin" }
kotlin-compose-gradle-plugin = { module = "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin", version.ref = "kotlin" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-gradle-plugin-api = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api", version.ref = "kotlin" }
kotlin-hierarchy-plugin = { module = "io.github.terrakok:kmp-hierarchy", version.ref = "kotlin-hierarchy" }
kotlin-multiplatform-gradle-plugin = { module = "org.jetbrains.kotlin.multiplatform:org.jetbrains.kotlin.multiplatform.gradle.plugin", version.ref = "kotlin" }
kotlin-inject-ksp = { module = "me.tatarka.inject:kotlin-inject-compiler-ksp", version.ref = "kotlin-inject" }
kotlin-inject-anvil-compiler = { module = "software.amazon.lastmile.kotlin.inject.anvil:compiler", version.ref = "kotlin-inject-anvil" }
kotlin-inject-anvil-runtime = { module = "software.amazon.lastmile.kotlin.inject.anvil:runtime", version.ref = "kotlin-inject-anvil" }
kotlin-inject-anvil-runtime-optional = { module = "software.amazon.lastmile.kotlin.inject.anvil:runtime-optional", version.ref = "kotlin-inject-anvil" }
kotlin-inject-runtime = { module = "me.tatarka.inject:kotlin-inject-runtime", version.ref = "kotlin-inject" }
kotlin-inject-runtime-kmp = { module = "me.tatarka.inject:kotlin-inject-runtime-kmp", version.ref = "kotlin-inject" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-script-runtime = { module = "org.jetbrains.kotlin:kotlin-script-runtime", version.ref = "kotlin" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
kotlinx-binaryCompatibilityValidator = { module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "kotlinx-binaryCompatibilityValidator" }
kotlin-poet = { module = "com.squareup:kotlinpoet", version.ref = "kotlin-poet" }
kotlin-poet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin-poet" }
ktfmt-gradle-plugin = { module = "com.ncorti.ktfmt.gradle:plugin", version.ref = "ktfmt-gradle" }
ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "ksp" }
ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
ksp-embeddable = { module = "com.google.devtools.ksp:symbol-processing-aa-embeddable", version.ref = "ksp" }
ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" }
maven-publish-gradle-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish" }
metro-compiler = { module = "dev.zacsweers.metro:compiler", version.ref = "metro" }
metro-gradle-plugin = { module = "dev.zacsweers.metro:gradle-plugin", version.ref = "metro" }
metro-runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
navigation-event-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigation-event" }
navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerView" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
#noinspection SimilarGradleDependency
viewbinding-api = { module = "androidx.databinding:viewbinding", version.ref = "viewbinding" }
#noinspection SimilarGradleDependency
viewbinding-agp = { module = "androidx.databinding:viewbinding", version.ref = "agp" }
[plugins]
android-app = { id = "com.android.application", version.ref = "agp" }
android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
android-lint = { id = "com.android.lint", version.ref = "agp" }
app-platform = { id = "software.amazon.app.platform" }
build-config = { id = "com.github.gmazzo.buildconfig", version.ref = "build-config" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
kotlin-hierarchy = { id = "io.github.terrakok.kmp-hierarchy", version.ref = "kotlin-hierarchy" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinx-binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlinx-binaryCompatibilityValidator" }
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: gradle-plugin/api/gradle-plugin.api
================================================
public class software/amazon/app/platform/gradle/AppPlatformExtension {
public fun (Lorg/gradle/api/model/ObjectFactory;Lorg/gradle/api/Project;)V
public final fun addImplModuleDependencies (Z)V
public final fun addPublicModuleDependencies (Z)V
public final fun enableComposeUi (Z)V
public final fun enableKotlinInject (Z)V
public final fun enableMetro (Z)V
public final fun enableModuleStructure (Z)V
public final fun enableMoleculePresenters (Z)V
}
public class software/amazon/app/platform/gradle/AppPlatformPlugin : org/gradle/api/Plugin {
public static final field Companion Lsoftware/amazon/app/platform/gradle/AppPlatformPlugin$Companion;
public fun ()V
public synthetic fun apply (Ljava/lang/Object;)V
public fun apply (Lorg/gradle/api/Project;)V
public static final fun exportedDependencies ()Ljava/util/Set;
}
public final class software/amazon/app/platform/gradle/AppPlatformPlugin$Companion {
public final fun exportedDependencies ()Ljava/util/Set;
}
public abstract class software/amazon/app/platform/gradle/ModuleStructureDependencyCheckTask : org/gradle/api/DefaultTask {
public static final field Companion Lsoftware/amazon/app/platform/gradle/ModuleStructureDependencyCheckTask$Companion;
public fun ()V
public final fun checkDependencies ()V
public abstract fun getIgnoredOutputFile ()Ljava/io/File;
public abstract fun getModuleCompileClasspath ()Ljava/util/Set;
public abstract fun getModulePath ()Ljava/lang/String;
public abstract fun setIgnoredOutputFile (Ljava/io/File;)V
public abstract fun setModuleCompileClasspath (Ljava/util/Set;)V
public abstract fun setModulePath (Ljava/lang/String;)V
}
public final class software/amazon/app/platform/gradle/ModuleStructureDependencyCheckTask$Companion {
public final fun registerModuleStructureDependencyCheckTask (Lorg/gradle/api/Project;)V
}
public class software/amazon/app/platform/gradle/ModuleStructurePlugin : org/gradle/api/Plugin {
public static final field Companion Lsoftware/amazon/app/platform/gradle/ModuleStructurePlugin$Companion;
public fun ()V
public synthetic fun apply (Ljava/lang/Object;)V
public fun apply (Lorg/gradle/api/Project;)V
}
public final class software/amazon/app/platform/gradle/ModuleStructurePlugin$Companion {
public final fun artifactId (Lorg/gradle/api/Project;Ljava/lang/String;)Ljava/lang/String;
public static synthetic fun artifactId$default (Lsoftware/amazon/app/platform/gradle/ModuleStructurePlugin$Companion;Lorg/gradle/api/Project;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String;
public final fun namespace (Lorg/gradle/api/Project;)Ljava/lang/String;
}
public final class software/amazon/app/platform/gradle/ModuleType : java/lang/Enum {
public static final field APP Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field IMPL Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field IMPL_ROBOTS Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field INTERNAL Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field INTERNAL_ROBOTS Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field PUBLIC Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field PUBLIC_ROBOTS Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field TESTING Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final field UNKNOWN Lsoftware/amazon/app/platform/gradle/ModuleType;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public final fun getUseTestDependenciesInMain ()Z
public final fun isRobotsModule ()Z
public static fun valueOf (Ljava/lang/String;)Lsoftware/amazon/app/platform/gradle/ModuleType;
public static fun values ()[Lsoftware/amazon/app/platform/gradle/ModuleType;
}
public final class software/amazon/app/platform/gradle/ModuleTypeKt {
public static final fun getModuleType (Lorg/gradle/api/Project;)Lsoftware/amazon/app/platform/gradle/ModuleType;
public static final fun isAnyImplModule (Lorg/gradle/api/Project;)Z
public static final fun isAnyInternalModule (Lorg/gradle/api/Project;)Z
public static final fun isAnyPublicModule (Lorg/gradle/api/Project;)Z
public static final fun isAppModule (Lorg/gradle/api/Project;)Z
public static final fun isImplModule (Lorg/gradle/api/Project;)Z
public static final fun isInternalModule (Lorg/gradle/api/Project;)Z
public static final fun isPublicModule (Lorg/gradle/api/Project;)Z
public static final fun isRobotsModule (Lorg/gradle/api/Project;)Z
public static final fun isTestingModule (Lorg/gradle/api/Project;)Z
public static final fun isUsingModuleStructure (Lorg/gradle/api/Project;)Z
}
================================================
FILE: gradle-plugin/build.gradle
================================================
//file:noinspection UnnecessaryQualifiedReference
plugins {
id 'java-gradle-plugin'
alias libs.plugins.kotlin.jvm
alias libs.plugins.ktfmt
alias libs.plugins.build.config
alias libs.plugins.maven.publish
alias libs.plugins.detekt
alias libs.plugins.kotlinx.binaryCompatibilityValidator
alias libs.plugins.android.lint
}
ktfmt {
googleStyle()
trailingCommaManagementStrategy.set(com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy.COMPLETE)
removeUnusedImports.set(true)
}
mavenPublishing {
pom {
name = "App Platform Gradle Plugin"
}
}
gradlePlugin {
plugins {
appPlatformPlugin {
id = "software.amazon.app.platform"
displayName = "App Platform Gradle Plugin"
implementationClass = "software.amazon.app.platform.gradle.AppPlatformPlugin"
description = "The Gradle plugin to make the integration of the App Platform easy."
}
}
}
buildConfig {
buildConfigField(String, 'KOTLIN_INJECT_VERSION', libs.versions.kotlin.inject.asProvider().get())
buildConfigField(String, 'KOTLIN_INJECT_ANVIL_VERSION', libs.versions.kotlin.inject.anvil.get())
buildConfigField(String, 'APP_PLATFORM_GROUP', property('GROUP'))
buildConfigField(String, 'APP_PLATFORM_VERSION', property('VERSION_NAME'))
buildConfigField(String, 'MOLECULE_VERSION', libs.versions.molecule.get())
buildConfigField(String, 'ANDROID_COMPOSE_VERSION', libs.versions.android.compose.version.get())
buildConfigField(String, 'COMPOSE_MULTIPLATFORM_VERSION', libs.versions.compose.multiplatform.get())
}
dependencies {
implementation libs.kotlin.gradle.plugin.api
// The Compose plugin is needed for Molecule and not Compose Multiplatform.
implementation libs.kotlin.compose.gradle.plugin
implementation libs.compose.gradle.plugin
// This is needed to reference KspExperimental for experimental features.
compileOnly libs.ksp.api
implementation libs.ksp.gradle.plugin
// compileOnly to not set a minimum version for any consumers of this Gradle plugin and
// because AGP is purely optional. Usage of AGP APIs is gated by checks when the plugin
// is applied.
compileOnly libs.android.gradle.plugin.api
// compileOnly, because not every consumer of this Gradle plugin will use KMP. All usages
// are guarded by checks when the plugin is applied.
compileOnly libs.kotlin.multiplatform.gradle.plugin
lintChecks libs.androidx.lint.gradle
}
java {
sourceCompatibility = libs.versions.jvm.compatibility.get()
targetCompatibility = libs.versions.jvm.compatibility.get()
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(libs.versions.jvm.compatibility.get()))
allWarningsAsErrors.set(true)
}
explicitApi()
}
tasks.withType(ValidatePlugins).configureEach {
it.enableStricterValidation = true
}
tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach {
it.jvmTarget = libs.versions.jvm.compatibility.get()
it.setSource(layout.files("src"))
}
//noinspection UnnecessaryQualifiedReference
tasks.withType(io.gitlab.arturbosch.detekt.DetektCreateBaselineTask).configureEach {
it.jvmTarget = libs.versions.jvm.compatibility.get()
it.setSource(layout.files("src"))
}
detekt {
config.from(file('../gradle/detekt-config.yml'))
buildUponDefaultConfig = true
}
tasks.register('release') {
dependsOn('build', 'check', 'ktfmtCheck', 'detekt', 'apiCheck')
}
================================================
FILE: gradle-plugin/settings.gradle
================================================
pluginManagement {
repositories {
gradlePluginPortal()
google()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
google()
gradlePluginPortal()
maven {
url = "https://central.sonatype.com/repository/maven-snapshots/"
}
}
versionCatalogs {
libs {
from files('../gradle/libs.versions.toml')
}
}
}
rootProject.name = 'gradle-plugin'
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/AppPlatformExtension.kt
================================================
package software.amazon.app.platform.gradle
import com.google.devtools.ksp.gradle.KspExtension
import gradle_plugin.BuildConfig.ANDROID_COMPOSE_VERSION
import gradle_plugin.BuildConfig.APP_PLATFORM_GROUP
import gradle_plugin.BuildConfig.APP_PLATFORM_VERSION
import gradle_plugin.BuildConfig.COMPOSE_MULTIPLATFORM_VERSION
import gradle_plugin.BuildConfig.KOTLIN_INJECT_ANVIL_VERSION
import gradle_plugin.BuildConfig.KOTLIN_INJECT_VERSION
import gradle_plugin.BuildConfig.MOLECULE_VERSION
import javax.inject.Inject
import org.gradle.api.Project
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.plugin.NATIVE_COMPILER_PLUGIN_CLASSPATH_CONFIGURATION_NAME
import org.jetbrains.kotlin.gradle.plugin.PLUGIN_CLASSPATH_CONFIGURATION_NAME
import software.amazon.app.platform.gradle.ModuleStructurePlugin.Companion.testingSourceSets
/**
* The extension to configure the App Platform. Following options are available:
* ```
* appPlatform {
* enableKotlinInject true // false is the default
* enableMetro true // false is the default
*
* enableMoleculePresenters true // false is the default
* enableModuleStructure true // false is the default
* enableComposeUi true // false is the default
*
* addPublicModuleDependencies true // false is the default
* addImplModuleDependencies true // false is the default
* }
* ```
*/
@Suppress("TooManyFunctions", "unused")
public open class AppPlatformExtension
@Inject
constructor(objects: ObjectFactory, private val project: Project) {
private val enableKotlinInject: Property =
objects.property(Boolean::class.java).convention(false)
/** Adds KSP and kotlin-inject as dependency. */
public fun enableKotlinInject(enabled: Boolean) {
if (enabled == enableKotlinInject.get()) return
enableKotlinInject.set(enabled)
enableKotlinInject.disallowChanges()
if (enabled) {
addPublicModuleDependencies(true)
project.enableKotlinInject()
}
}
internal fun isKotlinInjectEnabled(): Property = enableKotlinInject
private val enableMetro: Property =
objects.property(Boolean::class.java).convention(false)
/** Adds Metro as dependency. */
public fun enableMetro(enabled: Boolean) {
if (enabled == enableMetro.get()) return
enableMetro.set(enabled)
enableMetro.disallowChanges()
if (enabled) {
addPublicModuleDependencies(true)
project.enableMetro()
}
}
internal fun isMetroEnabled(): Property = enableMetro
private val enableMoleculePresenters: Property =
objects.property(Boolean::class.java).convention(false)
/** Adds the Molecule Gradle plugin as dependency and gives access to `MoleculePresenter`. */
public fun enableMoleculePresenters(enabled: Boolean) {
if (enabled == enableMoleculePresenters.get()) return
enableMoleculePresenters.set(enabled)
enableMoleculePresenters.disallowChanges()
if (enabled) {
addPublicModuleDependencies(true)
project.enableMoleculePresenters()
}
}
internal fun isMoleculeEnabled(): Property = enableMoleculePresenters
private val enableComposeUi: Property =
objects.property(Boolean::class.java).convention(false)
/** Adds necessary dependencies to use Compose Multiplatform with Renderers. */
public fun enableComposeUi(enabled: Boolean) {
if (enabled == enableComposeUi.get()) return
enableComposeUi.set(enabled)
enableComposeUi.disallowChanges()
if (enabled) {
addPublicModuleDependencies(true)
project.enableComposeUi()
}
}
internal fun isComposeUiEnabled(): Property = enableComposeUi
private val addImplModuleDependencies: Property =
objects.property(Boolean::class.java).convention(false)
/**
* Adds a dependency on all :impl modules. This is helpful for application modules that import all
* implementations.
*/
public fun addImplModuleDependencies(add: Boolean) {
addImplModuleDependencies.set(add)
addImplModuleDependencies.finalizeValueOnRead()
if (add) {
addPublicModuleDependencies(true)
}
}
internal fun isAddImplModuleDependencies(): Property = addImplModuleDependencies
private val addPublicModuleDependencies: Property =
objects.property(Boolean::class.java).convention(false)
/** Adds dependencies on `:public` modules for the Presenters, Renderers and Scopes. */
public fun addPublicModuleDependencies(add: Boolean) {
addPublicModuleDependencies.set(add)
addPublicModuleDependencies.finalizeValueOnRead()
}
internal fun isAddPublicModuleDependencies(): Property = addPublicModuleDependencies
private val enableModuleStructure: Property =
objects.property(Boolean::class.java).convention(false)
/** Sets up this module to use our recommended module structure and applies certain defaults. */
public fun enableModuleStructure(enable: Boolean) {
if (enable == enableModuleStructure.get()) return
enableModuleStructure.set(enable)
enableModuleStructure.disallowChanges()
if (enable) {
project.plugins.apply(ModuleStructurePlugin::class.java)
}
}
internal fun isModuleStructureEnabled(): Property = enableModuleStructure
internal companion object {
internal val Project.appPlatform: AppPlatformExtension
get() = extensions.getByType(AppPlatformExtension::class.java)
}
}
@Suppress("LongMethod")
private fun Project.enableKotlinInject() {
plugins.apply(PluginIds.KSP)
val kspExtension = extensions.getByType(KspExtension::class.java)
// Disable this processor, because we implement our own version in order to support the
// Scoped interface.
kspExtension.arg(
"software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesBindingProcessor",
"disabled",
)
fun DependencyHandler.addKspProcessorDependencies(kspConfigurationName: String) {
add(kspConfigurationName, "me.tatarka.inject:kotlin-inject-compiler-ksp:$KOTLIN_INJECT_VERSION")
add(
kspConfigurationName,
"$APP_PLATFORM_GROUP:kotlin-inject-contribute-public:$APP_PLATFORM_VERSION",
)
add(
kspConfigurationName,
"$APP_PLATFORM_GROUP:kotlin-inject-contribute-impl-code-generators:$APP_PLATFORM_VERSION",
)
add(
kspConfigurationName,
"software.amazon.lastmile.kotlin.inject.anvil:compiler:$KOTLIN_INJECT_ANVIL_VERSION",
)
}
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation("me.tatarka.inject:kotlin-inject-runtime:$KOTLIN_INJECT_VERSION")
implementation("$APP_PLATFORM_GROUP:di-common-public:$APP_PLATFORM_VERSION")
implementation("$APP_PLATFORM_GROUP:kotlin-inject-public:$APP_PLATFORM_VERSION")
implementation("$APP_PLATFORM_GROUP:kotlin-inject-contribute-public:$APP_PLATFORM_VERSION")
implementation(
"software.amazon.lastmile.kotlin.inject.anvil:runtime:$KOTLIN_INJECT_ANVIL_VERSION"
)
implementation(
"software.amazon.lastmile.kotlin.inject.anvil:runtime-optional:" +
KOTLIN_INJECT_ANVIL_VERSION
)
}
kmpExtension.targets.configureEach { target ->
addKspDependenciesWhenConfigExists(target) { configName ->
dependencies.addKspProcessorDependencies(configName)
}
}
}
plugins.withIds(PluginIds.KOTLIN_ANDROID, PluginIds.KOTLIN_JVM) {
dependencies.add("implementation", "$APP_PLATFORM_GROUP:di-common-public:$APP_PLATFORM_VERSION")
dependencies.add(
"implementation",
"$APP_PLATFORM_GROUP:kotlin-inject-public:$APP_PLATFORM_VERSION",
)
dependencies.add(
"implementation",
"$APP_PLATFORM_GROUP:kotlin-inject-contribute-public:$APP_PLATFORM_VERSION",
)
dependencies.add(
"implementation",
"software.amazon.lastmile.kotlin.inject.anvil:runtime:$KOTLIN_INJECT_ANVIL_VERSION",
)
dependencies.add(
"implementation",
"software.amazon.lastmile.kotlin.inject.anvil:runtime-optional:$KOTLIN_INJECT_ANVIL_VERSION",
)
dependencies.add(
"implementation",
"me.tatarka.inject:kotlin-inject-runtime:$KOTLIN_INJECT_VERSION",
)
dependencies.addKspProcessorDependencies("ksp")
}
}
private fun Project.enableMetro() {
plugins.apply(PluginIds.METRO)
val useMetroKsp =
providers.gradleProperty("app.platform.metro.ksp").map(String::toBoolean).orElse(false).get()
if (useMetroKsp) {
enableMetroKsp()
} else {
enableMetroCompilerPlugin()
}
}
private fun Project.enableMetroKsp() {
plugins.apply(PluginIds.KSP)
fun DependencyHandler.addKspProcessorDependencies(kspConfigurationName: String) {
add(
kspConfigurationName,
"$APP_PLATFORM_GROUP:metro-contribute-impl-code-generators:$APP_PLATFORM_VERSION",
)
}
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation("$APP_PLATFORM_GROUP:di-common-public:$APP_PLATFORM_VERSION")
implementation("$APP_PLATFORM_GROUP:metro-public:$APP_PLATFORM_VERSION")
}
kmpExtension.targets.configureEach { target ->
addKspDependenciesWhenConfigExists(target) { configName ->
dependencies.addKspProcessorDependencies(configName)
}
}
}
plugins.withIds(PluginIds.KOTLIN_ANDROID, PluginIds.KOTLIN_JVM) {
dependencies.add("implementation", "$APP_PLATFORM_GROUP:di-common-public:$APP_PLATFORM_VERSION")
dependencies.add("implementation", "$APP_PLATFORM_GROUP:metro-public:$APP_PLATFORM_VERSION")
dependencies.addKspProcessorDependencies("ksp")
}
}
private fun Project.enableMetroCompilerPlugin() {
fun DependencyHandler.addCompilerPluginDependencies() {
add(
PLUGIN_CLASSPATH_CONFIGURATION_NAME,
"$APP_PLATFORM_GROUP:metro-contribute-impl-compiler-plugin:$APP_PLATFORM_VERSION",
)
add(
NATIVE_COMPILER_PLUGIN_CLASSPATH_CONFIGURATION_NAME,
"$APP_PLATFORM_GROUP:metro-contribute-impl-compiler-plugin:$APP_PLATFORM_VERSION",
)
}
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation("$APP_PLATFORM_GROUP:di-common-public:$APP_PLATFORM_VERSION")
implementation("$APP_PLATFORM_GROUP:metro-public:$APP_PLATFORM_VERSION")
}
dependencies.addCompilerPluginDependencies()
}
plugins.withIds(PluginIds.KOTLIN_ANDROID, PluginIds.KOTLIN_JVM) {
dependencies.add("implementation", "$APP_PLATFORM_GROUP:di-common-public:$APP_PLATFORM_VERSION")
dependencies.add("implementation", "$APP_PLATFORM_GROUP:metro-public:$APP_PLATFORM_VERSION")
dependencies.addCompilerPluginDependencies()
}
}
private fun Project.enableMoleculePresenters() {
plugins.apply(PluginIds.COMPOSE_COMPILER)
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation("app.cash.molecule:molecule-runtime:$MOLECULE_VERSION")
implementation("$APP_PLATFORM_GROUP:presenter-molecule-public:$APP_PLATFORM_VERSION")
}
testingSourceSets.forEach { sourceSetName ->
kmpExtension.sourceSets.getByName(sourceSetName).dependencies {
implementation("$APP_PLATFORM_GROUP:presenter-molecule-testing:$APP_PLATFORM_VERSION")
}
}
}
plugins.withIds(PluginIds.KOTLIN_ANDROID, PluginIds.KOTLIN_JVM) {
dependencies.add("implementation", "app.cash.molecule:molecule-runtime:$MOLECULE_VERSION")
dependencies.add(
"implementation",
"$APP_PLATFORM_GROUP:presenter-molecule-public:$APP_PLATFORM_VERSION",
)
testingSourceSets.forEach { sourceSetName ->
dependencies.add(
sourceSetName,
"$APP_PLATFORM_GROUP:presenter-molecule-testing:$APP_PLATFORM_VERSION",
)
}
}
}
private fun Project.enableComposeUi() {
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
plugins.apply(PluginIds.COMPOSE_COMPILER)
plugins.apply(PluginIds.COMPOSE_MULTIPLATFORM)
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementation("org.jetbrains.compose.foundation:foundation:$COMPOSE_MULTIPLATFORM_VERSION")
implementation("org.jetbrains.compose.runtime:runtime:$COMPOSE_MULTIPLATFORM_VERSION")
implementation(
"$APP_PLATFORM_GROUP:renderer-compose-multiplatform-public:$APP_PLATFORM_VERSION"
)
if (isRobotsModule()) {
implementation(
"$APP_PLATFORM_GROUP:robot-compose-multiplatform-public:$APP_PLATFORM_VERSION"
)
}
}
}
plugins.withIds(PluginIds.KOTLIN_ANDROID) {
plugins.apply(PluginIds.COMPOSE_COMPILER)
android.buildFeatures.compose = true
dependencies.add("implementation", "androidx.compose.runtime:runtime:$ANDROID_COMPOSE_VERSION")
dependencies.add(
"implementation",
"androidx.compose.foundation:foundation:$ANDROID_COMPOSE_VERSION",
)
dependencies.add(
"implementation",
"$APP_PLATFORM_GROUP:renderer-compose-multiplatform-public:$APP_PLATFORM_VERSION",
)
if (isRobotsModule()) {
dependencies.add(
"implementation",
"$APP_PLATFORM_GROUP:robot-compose-multiplatform-public:$APP_PLATFORM_VERSION",
)
}
}
plugins.withIds(PluginIds.ANDROID_APP, PluginIds.ANDROID_LIBRARY) {
android.buildFeatures.compose = true
if (isAppModule()) {
dependencies.add(
"androidTestImplementation",
"$APP_PLATFORM_GROUP:robot-compose-multiplatform-public:$APP_PLATFORM_VERSION",
)
}
}
}
private fun Project.addKspDependenciesWhenConfigExists(
target: KotlinTarget,
block: (String) -> Unit,
) {
if (target.name != "metadata") {
target.compilations.configureEach { compilation ->
fun configExists(name: String): Boolean = configurations.any { it.name == name }
// Derive the KSP configuration name from the target name and compilation name.
// For main compilations: ksp (e.g. kspDesktop, kspIosSimulatorArm64)
// For test compilations: kspTest (e.g. kspDesktopTest)
val targetName = target.name.capitalize()
var configName =
if (compilation.name == "main") {
"ksp$targetName"
} else {
"ksp$targetName${compilation.name.capitalize()}"
}
if (!configExists(configName) && target.platformType == KotlinPlatformType.androidJvm) {
// Android has different naming for some reason.
//
// E.g. for instrumentation tests 'kspAndroidDebugAndroidTest' should actually be
// 'kspAndroidAndroidTestDebug', but we will use 'kspAndroidAndroidTest'.
//
// For unit tests 'kspAndroidDebugUnitTest' should actually be 'kspAndroidTestDebug',
// but we will use 'kspAndroidTest'.
when {
configName.endsWith("AndroidTest") -> configName = "kspAndroidAndroidTest"
configName.endsWith("UnitTest") -> configName = "kspAndroidTest"
}
}
// Check again if the config exists.
if (configExists(configName)) {
block(configName)
}
}
}
}
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/AppPlatformPlugin.kt
================================================
package software.amazon.app.platform.gradle
import gradle_plugin.BuildConfig.APP_PLATFORM_GROUP
import gradle_plugin.BuildConfig.APP_PLATFORM_VERSION
import org.gradle.api.Plugin
import org.gradle.api.Project
import software.amazon.app.platform.gradle.AppPlatformExtension.Companion.appPlatform
import software.amazon.app.platform.gradle.ModuleStructurePlugin.Companion.testingSourceSets
/** The Gradle plugin to make the integration of the App Platform easy. */
@Suppress("unused")
public open class AppPlatformPlugin : Plugin {
override fun apply(target: Project) {
target.extensions.create("appPlatform", AppPlatformExtension::class.java)
target.afterEvaluate {
target.addPublicDependencies()
target.addImplDependencies()
}
}
@Suppress("LongMethod")
private fun Project.addPublicDependencies() {
if (!appPlatform.isAddPublicModuleDependencies().get()) {
// If disabled, then don't add these dependencies.
return
}
val implementationDependencies =
setOf(
"$APP_PLATFORM_GROUP:presenter-public:$APP_PLATFORM_VERSION",
"$APP_PLATFORM_GROUP:renderer-public:$APP_PLATFORM_VERSION",
"$APP_PLATFORM_GROUP:scope-public:$APP_PLATFORM_VERSION",
)
val testImplementationDependencies =
setOf("$APP_PLATFORM_GROUP:scope-testing:$APP_PLATFORM_VERSION")
val robotDependencies = setOf("$APP_PLATFORM_GROUP:robot-public:$APP_PLATFORM_VERSION")
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementationDependencies.forEach { dep -> implementation(dep) }
if (isRobotsModule()) {
robotDependencies.forEach { dep -> implementation(dep) }
}
}
testingSourceSets.forEach { sourceSetName ->
kmpExtension.sourceSets.getByName(sourceSetName).dependencies {
testImplementationDependencies.forEach { dep -> implementation(dep) }
}
}
}
plugins.withIds(PluginIds.KOTLIN_ANDROID, PluginIds.KOTLIN_JVM) {
implementationDependencies.forEach { dep -> dependencies.add("implementation", dep) }
testingSourceSets.forEach { sourceSetName ->
testImplementationDependencies.forEach { dep -> dependencies.add(sourceSetName, dep) }
}
if (isRobotsModule()) {
robotDependencies.forEach { dep -> dependencies.add("implementation", dep) }
}
}
plugins.withId(PluginIds.ANDROID_KMP_LIBRARY) {
dependencies.add(
"androidMainImplementation",
"$APP_PLATFORM_GROUP:renderer-android-view-public:$APP_PLATFORM_VERSION",
)
}
plugins.withIds(PluginIds.ANDROID_APP, PluginIds.ANDROID_LIBRARY) {
dependencies.add(
"implementation",
"$APP_PLATFORM_GROUP:renderer-android-view-public:$APP_PLATFORM_VERSION",
)
if (isAppModule()) {
robotDependencies.forEach { dep -> dependencies.add("androidTestImplementation", dep) }
}
}
}
private fun Project.addImplDependencies() {
if (!appPlatform.isAddImplModuleDependencies().get()) {
// If disabled, then don't add these dependencies.
return
}
val implementationDependencies = buildSet {
if (appPlatform.isMoleculeEnabled().get()) {
add("$APP_PLATFORM_GROUP:presenter-molecule-impl:$APP_PLATFORM_VERSION")
}
if (appPlatform.isKotlinInjectEnabled().get()) {
add("$APP_PLATFORM_GROUP:kotlin-inject-impl:$APP_PLATFORM_VERSION")
}
if (appPlatform.isMetroEnabled().get()) {
add("$APP_PLATFORM_GROUP:metro-impl:$APP_PLATFORM_VERSION")
}
}
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.sourceSets.getByName("commonMain").dependencies {
implementationDependencies.forEach { dep -> implementation(dep) }
}
}
plugins.withIds(PluginIds.KOTLIN_ANDROID, PluginIds.KOTLIN_JVM) {
implementationDependencies.forEach { dep -> dependencies.add("implementation", dep) }
}
}
public companion object {
/**
* Returns the set of dependencies that need to be exported in a Framework for native targets in
* order to make App Platform work.
*/
@JvmStatic
public fun exportedDependencies(): Set =
setOf(
"di-common-public",
"kotlin-inject-contribute-public",
"kotlin-inject-impl",
"kotlin-inject-public",
"metro-impl",
"metro-public",
"presenter-molecule-impl",
"presenter-molecule-public",
"presenter-public",
"renderer-compose-multiplatform-public",
"renderer-public",
"scope-public",
)
.mapTo(mutableSetOf()) { "$APP_PLATFORM_GROUP:$it:$APP_PLATFORM_VERSION" }
}
}
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/GradleExtensions.kt
================================================
package software.amazon.app.platform.gradle
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.variant.AndroidComponentsExtension
import java.util.Locale
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.UnknownTaskException
import org.gradle.api.plugins.PluginContainer
import org.gradle.api.project.IsolatedProject
import org.gradle.api.tasks.TaskContainer
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
internal fun PluginContainer.withIds(vararg pluginIds: String, action: (Plugin<*>) -> Unit) {
pluginIds.forEach { id -> withId(id) { action(it) } }
}
// This is OK because no properties within parent are accessed
// https://github.com/gradle/gradle/issues/33198
@Suppress("GradleProjectIsolation")
internal fun Project.requireParent(): IsolatedProject =
requireNotNull(parent) {
"The parent project for a module enabling the module structure should not be null."
}
.isolated
internal val Project.isKmpModule: Boolean
get() = plugins.hasPlugin(PluginIds.KOTLIN_MULTIPLATFORM)
internal val Project.android: CommonExtension<*, *, *, *, *, *>
get() = extensions.getByType(CommonExtension::class.java)
internal val Project.androidComponents: AndroidComponentsExtension<*, *, *>
get() = extensions.getByType(AndroidComponentsExtension::class.java)
internal val Project.kmpExtension: KotlinMultiplatformExtension
get() = extensions.getByType(KotlinMultiplatformExtension::class.java)
internal fun TaskContainer.namedOptional(name: String, configurationAction: (Task) -> Unit) {
try {
named(name, configurationAction)
} catch (_: UnknownTaskException) {}
}
internal fun String.capitalize(): String = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString()
}
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/ModuleStructureDependencyCheckTask.kt
================================================
package software.amazon.app.platform.gradle
import java.io.File
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.ExternalDependency
import org.gradle.api.artifacts.ProjectDependency
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
/** Checks that our module structure dependency rules are followed. */
@CacheableTask
public abstract class ModuleStructureDependencyCheckTask : DefaultTask() {
/** The path of this module, e.g. `:presenter:public`. */
@get:Input public abstract var modulePath: String
/** All Gradle modules on the compile classpath. */
@get:Input public abstract var moduleCompileClasspath: Set
/** An empty output makes the task work with up-to-date checks. */
@Suppress("unused") @get:OutputFile @get:Optional public abstract var ignoredOutputFile: File
init {
description = "Checks that our module structure dependency rules are followed."
group = "Verification"
}
@TaskAction
@PublishedApi
internal fun checkDependencies() {
val moduleType = modulePath.moduleType
if (moduleType == ModuleType.PUBLIC) {
checkOnlyPublicModule()
}
if (moduleType != ModuleType.APP && moduleType != ModuleType.IMPL_ROBOTS) {
checkNoImplImport()
}
if (moduleType != ModuleType.TESTING && !moduleType.isRobotsModule) {
checkNoTestingImport()
}
if (!moduleType.isRobotsModule) {
checkNoRobotsImport()
}
if (moduleType != ModuleType.APP) {
checkNoInternalImportFromOtherLibrary()
}
}
private fun checkOnlyPublicModule() {
val forbiddenDependencies = moduleCompileClasspath.filter { it.moduleType != ModuleType.PUBLIC }
if (forbiddenDependencies.isNotEmpty()) {
throw GradleException(
":public modules are only allowed to depend on other :public modules. " +
"Remove the dependencies: ${forbiddenDependencies.joinToString()} " +
"from $modulePath."
)
}
}
private fun checkNoImplImport() {
val forbiddenDependencies = moduleCompileClasspath.filter { it.moduleType == ModuleType.IMPL }
if (forbiddenDependencies.isNotEmpty()) {
throw GradleException(
"No module except for an app module is allowed to import an :impl module. " +
"Remove the dependencies: ${forbiddenDependencies.joinToString()} " +
"from $modulePath."
)
}
}
private fun checkNoTestingImport() {
val forbiddenDependencies = moduleCompileClasspath.filter {
it.moduleType == ModuleType.TESTING
}
if (forbiddenDependencies.isNotEmpty()) {
throw GradleException(
"Testing modules should be added to the test compile classpath, otherwise " +
"they're included in the final app. Remove the dependencies: " +
"${forbiddenDependencies.joinToString()} from $modulePath."
)
}
}
private fun checkNoRobotsImport() {
val forbiddenDependencies = moduleCompileClasspath.filter { it.moduleType.isRobotsModule }
if (forbiddenDependencies.isNotEmpty()) {
throw GradleException(
"Robot modules should be added to the instrumented test compile classpath, " +
"otherwise they're included in the final app. Remove the dependencies: " +
"${forbiddenDependencies.joinToString()} from $modulePath."
)
}
}
private fun checkNoInternalImportFromOtherLibrary() {
val forbiddenDependencies =
moduleCompileClasspath
.filter { it.moduleType == ModuleType.INTERNAL }
.filter { dependency ->
// Usually :internal modules are part of the same Gradle project, therefore the
// dependency string starts with a colon ":", e.g. :library:internal. If that's
// the case, then compare the parent path with this project's parent path. If
// they match, then the :internal dependency is allowed. If they don't match,
// then the dependency is forbidden.
//
// For external dependencies this check is much harder and for now we simply
// assume that the internal dependency isn't allowed.
if (dependency.startsWith(":")) {
dependency.substringBeforeLast(':') != modulePath.substringBeforeLast(':')
} else {
// It's an external dependency
true
}
}
if (forbiddenDependencies.isNotEmpty()) {
throw GradleException(
"Internal modules can only be imported within the same library or by app " +
"modules, but not from another library. Remove the dependencies: " +
"${forbiddenDependencies.joinToString()} from $modulePath."
)
}
}
private val String.moduleType: ModuleType
get() =
if (startsWith(':')) {
moduleTypeFromProjectPath()
} else {
substringAfter(':').substringBefore(':').moduleTypeFromArtifactId()
}
public companion object {
/** Registers the task in the given project. */
public fun Project.registerModuleStructureDependencyCheckTask() {
val baseTaskName = "checkModuleStructureDependencies"
val baseTask =
tasks.register(baseTaskName) {
it.description = "Checks that our module structure dependency rules for all targets."
it.group = "Verification"
}
afterEvaluate { tasks.namedOptional("check") { it.dependsOn(baseTask) } }
fun registerForConfiguration(taskSuffix: String, configuration: () -> Configuration) {
val checkTask =
tasks.register(
"$baseTaskName${taskSuffix.capitalize()}",
ModuleStructureDependencyCheckTask::class.java,
) { task ->
task.modulePath = path
task.moduleCompileClasspath =
configuration()
.allDependencies
.mapNotNull { dependency ->
when (dependency) {
is ExternalDependency -> {
"${dependency.group}:${dependency.name}:${dependency.version}"
.takeIf { dependency.name.moduleTypeFromArtifactId() != ModuleType.UNKNOWN }
}
is ProjectDependency -> {
dependency.path.takeIf {
it.moduleTypeFromProjectPath() != ModuleType.UNKNOWN
}
}
else -> null
}
}
.toSet()
}
baseTask.configure { it.dependsOn(checkTask) }
}
plugins.withIds(PluginIds.ANDROID_LIBRARY, PluginIds.ANDROID_APP) {
androidComponents.onVariants { variant ->
registerForConfiguration(
taskSuffix = "android${variant.name.capitalize()}",
configuration = { variant.compileConfiguration },
)
}
}
plugins.withId(PluginIds.KOTLIN_MULTIPLATFORM) {
kmpExtension.targets.configureEach { target ->
// We register Android above.
if (target.name == "android") return@configureEach
target.compilations.configureEach configureEach2@{ compilation ->
// We only care about main.
if (compilation.name != "main") return@configureEach2
registerForConfiguration(
taskSuffix = target.name,
configuration = {
configurations.getByName(compilation.compileDependencyConfigurationName)
},
)
}
}
}
plugins.withId(PluginIds.KOTLIN_JVM) {
registerForConfiguration(
taskSuffix = "jvm",
configuration = { configurations.getByName("compileClasspath") },
)
}
}
}
}
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/ModuleStructurePlugin.kt
================================================
package software.amazon.app.platform.gradle
import com.android.build.api.dsl.androidLibrary
import org.gradle.api.Plugin
import org.gradle.api.Project
import software.amazon.app.platform.gradle.ModuleStructureDependencyCheckTask.Companion.registerModuleStructureDependencyCheckTask
/** The Gradle plugin that sets up our module structure. */
public open class ModuleStructurePlugin : Plugin {
override fun apply(target: Project) {
target.ensureFollowsNamingConvention()
target.addModuleStructureDependencies()
target.configureAndroidNamespace()
target.registerModuleStructureDependencyCheckTask()
}
private fun Project.ensureFollowsNamingConvention() {
check(isUsingModuleStructure()) {
"$path enables the module structure, but the project name doesn't follow the naming convention."
}
}
private fun Project.addModuleStructureDependencies() {
plugins.withIds(
PluginIds.KOTLIN_MULTIPLATFORM,
PluginIds.KOTLIN_JVM,
PluginIds.KOTLIN_ANDROID,
) {
val parent = requireParent()
// Nothing to add.
if (isPublicModule()) return@withIds
fun addPublicModule() {
// this is ok because no properties within publicModule are accessed
@Suppress("GradleProjectIsolation") val publicModule = findProject("${parent.path}:public")
if (publicModule != null) {
if (isKmpModule) {
dependencies.add("commonMainApi", publicModule)
} else {
dependencies.add("api", publicModule)
}
}
}
when {
isTestingModule() -> {
// :testing modules provide helper functions or fake implementations of the
// APIs in the :public module.
addPublicModule()
}
isImplModule() || isInternalModule() -> {
// :impl and :internal modules implement interfaces and types from the :public
// module.
addPublicModule()
}
isRobotsModule() -> {
// :robot modules usually reference types from the :public and :impl modules.
addPublicModule()
// Add a dependency to the implementation module. Note that an "implementation"
// dependency is chosen rather than an "api" dependency. The goal of the a
// robots module to hide all details of the :impl module and only expose
// abstractions with the help of robots.
@Suppress("GradleProjectIsolation") // no properties within project are accessed
findProject(path.substringBefore("-robots"))
?.takeIf { it.isImplModule() }
?.let { implModule ->
if (isKmpModule) {
dependencies.add("commonMainImplementation", implModule)
} else {
dependencies.add("implementation", implModule)
}
}
}
}
}
}
private fun Project.configureAndroidNamespace() {
plugins.withIds(PluginIds.ANDROID_APP, PluginIds.ANDROID_LIBRARY) {
// Do not override any configured namespace.
if (android.namespace == null) {
android.namespace = namespace()
}
}
plugins.withId(PluginIds.ANDROID_KMP_LIBRARY) {
@Suppress("UnstableApiUsage")
kmpExtension.androidLibrary {
if (namespace == null) {
namespace = namespace()
}
}
}
}
public companion object {
/**
* Returns a consistent namespace for a Gradle module that has the recommended App Platform
* module structure in mind. It helps to avoid clashing namespaces across projects.
*
* This value can be used as namespace for Android projects and gets automatically set when no
* other namespace is declared.
*
* It requires that the `GROUP` property is set for the Gradle project.
*
* E.g. it produces following results:
* ```
* GROUP=software.amazon.abc
*
* :def:public -> "software.amazon.abc.def"
* :def:impl -> "software.amazon.abc.def.impl"
* :def:impl-ghj-robots -> "software.amazon.abc.def.impl.ghj.robots"
* ```
*
* @see com.android.build.api.dsl.CommonExtension.namespace
*/
public fun Project.namespace(): String {
val group =
providers.gradleProperty("GROUP").let {
check(it.isPresent) {
"Couldn't find the GROUP property for this project. Make sure you define " +
"a group in the project's gradle.properties file, e.g. `GROUP=" +
"software.amazon.abc`."
}
return@let it.get()
}
val path =
when {
isPublicModule() -> requireParent().path
isAnyPublicModule() && isRobotsModule() -> "${requireParent().path}:robots"
else -> path
}
return "$group${path.replace(':', '.').replace('-', '.')}"
}
/**
* Returns a consistent artifact ID for a Gradle module that has the recommended App Platform
* module structure in mind. This artifact ID should be used for publishing library modules.
*
* It produces following results:
* ```
* :abc:public -> "abc-public"
* :abc:impl-def-robots -> "abc-impl-def-robots"
* ```
*/
public fun Project.artifactId(libraryName: String = requireParent().name): String {
return "$libraryName-$name"
}
internal val Project.testingSourceSets: List
get() = buildList {
when {
plugins.hasPlugin(PluginIds.KOTLIN_MULTIPLATFORM) -> {
add("commonTest")
if (moduleType.useTestDependenciesInMain) {
add("commonMain")
}
}
plugins.hasPlugin(PluginIds.KOTLIN_ANDROID) ||
plugins.hasPlugin(PluginIds.KOTLIN_JVM) -> {
add("testImplementation")
if (moduleType.useTestDependenciesInMain) {
add("implementation")
}
}
}
}
}
}
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/ModuleType.kt
================================================
@file:Suppress("TooManyFunctions", "unused")
package software.amazon.app.platform.gradle
import org.gradle.api.Project
import software.amazon.app.platform.gradle.ModuleType.APP
import software.amazon.app.platform.gradle.ModuleType.IMPL
import software.amazon.app.platform.gradle.ModuleType.IMPL_ROBOTS
import software.amazon.app.platform.gradle.ModuleType.INTERNAL
import software.amazon.app.platform.gradle.ModuleType.INTERNAL_ROBOTS
import software.amazon.app.platform.gradle.ModuleType.PUBLIC
import software.amazon.app.platform.gradle.ModuleType.PUBLIC_ROBOTS
import software.amazon.app.platform.gradle.ModuleType.TESTING
import software.amazon.app.platform.gradle.ModuleType.UNKNOWN
/** The type of module based on our module structure. */
public enum class ModuleType(
/** Whether this type is a robots module. Robot modules are used for instrumented tests. */
public val isRobotsModule: Boolean = false,
/**
* Whether dependencies that typically used only in tests are part of the main source set, e.g.
* that's the case for `:testing` and robot modules.
*/
public val useTestDependenciesInMain: Boolean = false,
) {
/**
* `:app` modules refer to the final application, where all feature implementations are imported
* and assembled as a single binary. Therefore, `:app` modules are allowed to depend on `:impl`
* modules of all imported libraries and features.
*
* App modules are leaf modules prefixed with "app" or live in a folder named "app".
*/
APP,
/**
* `:public` modules contain the code that should be shared and reused by other modules and
* libraries. APIs (interfaces) usually live in `:public` modules, but also code where dependency
* inversion isn’t applied such as static utilities, extension functions and UI components.
*/
PUBLIC,
/** `:public-robots` host robots or test code for a `:public` module. */
PUBLIC_ROBOTS(isRobotsModule = true, useTestDependenciesInMain = true),
/**
* `:testing` modules provide a mechanism to share utilities or fake implementations for tests
* with other libraries. `:testing` modules are allowed to be imported as test dependency by any
* other module type and are never added to the runtime classpath. Even its own `:public` module
* can reuse the code from the `:testing` module for its tests.
*/
TESTING(useTestDependenciesInMain = true),
/**
* `:impl` modules contain the concrete implementations of the API from `:public` modules. A
* library can have zero or more `:impl` modules. If a library contains multiple `:impl` modules,
* then they’re suffixed, e.g. `:login:impl-amazon` and `:login:impl-google`.
*/
IMPL,
/**
* `:*-robots` modules help implementing the robot pattern for UI tests and make them shareable.
* Robots must know about concrete implementations, therefore they usually depend on an `:impl`
* module, but don't expose this `:impl` module on the compile classpath. `:robot` modules are
* only imported and reused for UI tests and are never added as dependency to the runtime
* classpath of a module similar to `:testing` modules.
*/
IMPL_ROBOTS(isRobotsModule = true, useTestDependenciesInMain = true),
/**
* `:internal` modules are used when code should be shared between multiple `:impl` modules of the
* same library, but the code should not be exposed through the `:public` module. This code is
* "internal" to this library.
*/
INTERNAL,
/** `:internal-robots` host robots or test code for an `:internal` module. */
INTERNAL_ROBOTS(isRobotsModule = true, useTestDependenciesInMain = true),
/**
* The module type could not be parsed, likely because the module is not following the module
* structure.
*/
UNKNOWN,
}
/** The type of module based on our module structure. */
public val Project.moduleType: ModuleType
get() = path.moduleTypeFromProjectPath()
internal fun String.moduleTypeFromProjectPath(): ModuleType {
val name = substringAfterLast(':')
val isRobots = name.endsWith("-robots")
return when {
name.startsWith("public") -> if (isRobots) PUBLIC_ROBOTS else PUBLIC
name == "testing" -> TESTING
name.startsWith("impl") -> if (isRobots) IMPL_ROBOTS else IMPL
name.startsWith("internal") -> if (isRobots) INTERNAL_ROBOTS else INTERNAL
contains(":app:") || name.startsWith("app") -> APP
else -> UNKNOWN
}
}
internal fun String.moduleTypeFromArtifactId(): ModuleType {
// E.g. abc-public, def-impl-xyz-robots
return when {
endsWith("-public-robots") -> PUBLIC_ROBOTS
endsWith("-public") -> PUBLIC
endsWith("-testing") -> TESTING
endsWith("-impl") -> IMPL
contains("-impl-") -> if (endsWith("-robots")) IMPL_ROBOTS else IMPL
endsWith("-internal") -> INTERNAL
contains("-internal-") -> if (endsWith("-robots")) INTERNAL_ROBOTS else INTERNAL
this == "app" -> APP
startsWith("app-") -> APP
else -> UNKNOWN
}
}
/**
* Returns true for app modules. Typically, these modules are leaf modules prefixed with "app" or
* live in a folder named "app".
*/
public fun Project.isAppModule(): Boolean = moduleType == APP
/** Returns true for any public module including robots module. */
public fun Project.isAnyPublicModule(): Boolean =
moduleType == PUBLIC || moduleType == PUBLIC_ROBOTS
/** Returns true for the public module of a library, but not subtypes, e.g. a robots module. */
public fun Project.isPublicModule(): Boolean = moduleType == PUBLIC
/** Returns true for the testing module of a library. */
public fun Project.isTestingModule(): Boolean = moduleType == TESTING
/** Returns true for any impl module including robots module. */
public fun Project.isAnyImplModule(): Boolean = moduleType == IMPL || moduleType == IMPL_ROBOTS
/** Returns true for an impl module, but not subtypes, e.g. a robots module. */
public fun Project.isImplModule(): Boolean = moduleType == IMPL
/** Returns true for an internal module, but not subtypes, e.g. a robots module. */
public fun Project.isAnyInternalModule(): Boolean =
moduleType == INTERNAL || moduleType == INTERNAL_ROBOTS
/** Returns true for an internal module, but not subtypes, e.g. a robots module. */
public fun Project.isInternalModule(): Boolean = moduleType == INTERNAL
/** Returns true for any robots module. */
public fun Project.isRobotsModule(): Boolean = moduleType.isRobotsModule
/** Checks whether the project follows the naming convention of the module structure. */
public fun Project.isUsingModuleStructure(): Boolean = moduleType != UNKNOWN
================================================
FILE: gradle-plugin/src/main/kotlin/software/amazon/app/platform/gradle/PluginIds.kt
================================================
package software.amazon.app.platform.gradle
internal object PluginIds {
const val ANDROID_APP = "com.android.application"
const val ANDROID_KMP_LIBRARY = "com.android.kotlin.multiplatform.library"
const val ANDROID_LIBRARY = "com.android.library"
const val COMPOSE_COMPILER = "org.jetbrains.kotlin.plugin.compose"
const val COMPOSE_MULTIPLATFORM = "org.jetbrains.compose"
const val KOTLIN_MULTIPLATFORM = "org.jetbrains.kotlin.multiplatform"
const val KOTLIN_JVM = "org.jetbrains.kotlin.jvm"
const val KOTLIN_ANDROID = "org.jetbrains.kotlin.android"
const val KSP = "com.google.devtools.ksp"
const val METRO = "dev.zacsweers.metro"
}
================================================
FILE: gradle.properties
================================================
VERSION_NAME=0.0.11-SNAPSHOT
GROUP=software.amazon.app.platform
org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache.parallel=true
kotlin.mpp.stability.nowarn=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.mpp.enableCInteropCommonization=true
kotlin.native.distribution.downloadFromMaven=true
# https://youtrack.jetbrains.com/issue/KT-82395
kotlin.incremental.js=false
kotlin.incremental.js.klib=false
org.jetbrains.compose.experimental.uikit.enabled=true
# This property does not work when setting up publishing through the DSL as we do.
# SONATYPE_AUTOMATIC_RELEASE=true
SONATYPE_HOST=CENTRAL_PORTAL
# Keep this set to false by default, otherwise publishing to Maven local is extremely slow. There is a bug:
# https://github.com/gradle/gradle/issues/26256
RELEASE_SIGNING_ENABLED=false
POM_DESCRIPTION=The App Platform is a lightweight application framework for state and memory management suitable for Kotlin Multiplatform projects, in particular Android, iOS, JVM, native and Web.
POM_INCEPTION_YEAR=2025
POM_URL=https://github.com/amzn/app-platform/
POM_SCM_URL=https://github.com/amzn/app-platform/
POM_SCM_CONNECTION=scm:git:git://github.com/amzn/app-platform.git
POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/amzn/app-platform.git
POM_LICENCE_NAME=Apache-2.0
POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0
POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=last-mile-dat
POM_DEVELOPER_NAME=Driver Assistance Technology
POM_DEVELOPER_URL=https://github.com/amzn
android.useAndroidX=true
android.enableJetifier=false
android.nonTransitiveRClass=true
android.defaults.buildfeatures.buildconfig=false
android.defaults.buildfeatures.aidl=false
android.defaults.buildfeatures.renderscript=false
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
================================================
FILE: gradlew
================================================
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
================================================
FILE: gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: internal/testing/build.gradle
================================================
plugins {
id 'software.amazon.app.platform.lib'
}
================================================
FILE: internal/testing/src/androidMain/kotlin/software/amazon/app/platform/internal/IgnoreNative.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Native platforms. */
@Target(CLASS, FUNCTION) actual annotation class IgnoreNative actual constructor()
================================================
FILE: internal/testing/src/androidMain/kotlin/software/amazon/app/platform/internal/IgnoreWasm.android.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Wasm. */
@Target(CLASS, FUNCTION) actual annotation class IgnoreWasm actual constructor()
================================================
FILE: internal/testing/src/androidMain/kotlin/software/amazon/app/platform/internal/Platform.kt
================================================
package software.amazon.app.platform.internal
/** The current test environment target. */
actual val platform: Platform = Platform.JVM
================================================
FILE: internal/testing/src/androidMain/kotlin/software/amazon/app/platform/internal/Thread.kt
================================================
package software.amazon.app.platform.internal
/** Provides the name of the current thread this is called on. */
actual val currentThreadName: String
get() = Thread.currentThread().name
================================================
FILE: internal/testing/src/commonMain/kotlin/software/amazon/app/platform/internal/IgnoreNative.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Native platforms. */
@Target(CLASS, FUNCTION) expect annotation class IgnoreNative()
================================================
FILE: internal/testing/src/commonMain/kotlin/software/amazon/app/platform/internal/IgnoreWasm.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Wasm. */
@Target(CLASS, FUNCTION) expect annotation class IgnoreWasm()
================================================
FILE: internal/testing/src/commonMain/kotlin/software/amazon/app/platform/internal/Platform.kt
================================================
package software.amazon.app.platform.internal
/** All test environment targets. */
enum class Platform {
/** The JVM target includes Android and Desktop. */
JVM,
/** The Native target includes Apple and Linux. */
Native,
/** The Web target includes Wasm. */
Web,
}
/** The current test environment target. */
expect val platform: Platform
================================================
FILE: internal/testing/src/commonMain/kotlin/software/amazon/app/platform/internal/Thread.kt
================================================
package software.amazon.app.platform.internal
/** Provides the name of the current thread this is called on. */
expect val currentThreadName: String
================================================
FILE: internal/testing/src/desktopMain/kotlin/software/amazon/app/platform/internal/IgnoreNative.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Native platforms. */
@Target(CLASS, FUNCTION) actual annotation class IgnoreNative actual constructor()
================================================
FILE: internal/testing/src/desktopMain/kotlin/software/amazon/app/platform/internal/IgnoreWasm.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Wasm. */
@Target(CLASS, FUNCTION) actual annotation class IgnoreWasm actual constructor()
================================================
FILE: internal/testing/src/desktopMain/kotlin/software/amazon/app/platform/internal/Platform.kt
================================================
package software.amazon.app.platform.internal
/** The current test environment target. */
actual val platform: Platform = Platform.JVM
================================================
FILE: internal/testing/src/desktopMain/kotlin/software/amazon/app/platform/internal/Thread.kt
================================================
package software.amazon.app.platform.internal
/** Provides the name of the current thread this is called on. */
actual val currentThreadName: String
get() = Thread.currentThread().name
================================================
FILE: internal/testing/src/nativeMain/kotlin/software/amazon/app/platform/internal/IgnoreNative.kt
================================================
package software.amazon.app.platform.internal
/** Skips annotated tests on Native platforms. */
actual typealias IgnoreNative = kotlin.test.Ignore
================================================
FILE: internal/testing/src/nativeMain/kotlin/software/amazon/app/platform/internal/IgnoreWasm.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Wasm. */
@Target(CLASS, FUNCTION) actual annotation class IgnoreWasm actual constructor()
================================================
FILE: internal/testing/src/nativeMain/kotlin/software/amazon/app/platform/internal/Platform.kt
================================================
package software.amazon.app.platform.internal
/** The current test environment target. */
actual val platform: Platform = Platform.Native
================================================
FILE: internal/testing/src/nativeMain/kotlin/software/amazon/app/platform/internal/Thread.kt
================================================
package software.amazon.app.platform.internal
/** Provides the name of the current thread this is called on. */
actual val currentThreadName: String = throw NotImplementedError()
================================================
FILE: internal/testing/src/wasmJsMain/kotlin/software/amazon/app/platform/internal/IgnoreNative.kt
================================================
package software.amazon.app.platform.internal
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FUNCTION
/** Skips annotated tests on Native platforms. */
@Target(CLASS, FUNCTION) actual annotation class IgnoreNative actual constructor()
================================================
FILE: internal/testing/src/wasmJsMain/kotlin/software/amazon/app/platform/internal/IgnoreWasm.kt
================================================
package software.amazon.app.platform.internal
/** Skips annotated tests on Wasm. */
actual typealias IgnoreWasm = kotlin.test.Ignore
================================================
FILE: internal/testing/src/wasmJsMain/kotlin/software/amazon/app/platform/internal/Platform.kt
================================================
package software.amazon.app.platform.internal
/** The current test environment target. */
actual val platform: Platform = Platform.Web
================================================
FILE: internal/testing/src/wasmJsMain/kotlin/software/amazon/app/platform/internal/Thread.kt
================================================
package software.amazon.app.platform.internal
/** Provides the name of the current thread this is called on. */
actual val currentThreadName: String = throw NotImplementedError()
================================================
FILE: ios-run.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
APP_KEY=""
APP_LABEL=""
PROJECT_PATH=""
SCHEME=""
DERIVED_DATA_PATH=""
SIMULATOR_NAMES=()
SIMULATOR_UDIDS=()
SIMULATOR_STATES=()
SIMULATOR_RUNTIMES=()
PROMPT_SELECTION_INDEX=""
SELECTED_SIMULATOR_UDID=""
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Missing required command: $command_name" >&2
exit 1
fi
}
choose_application() {
while true; do
cat <<'EOF'
Which application do you want to run?
1) sample
2) recipes
3) starter blueprint
EOF
printf "Enter selection [1-3]: "
read -r selection
case "$selection" in
1)
APP_KEY="sample"
APP_LABEL="sample"
PROJECT_PATH="$ROOT_DIR/sample/iosApp/iosApp.xcodeproj"
SCHEME="iosApp"
DERIVED_DATA_PATH="/tmp/app-platform-ios-run-sample"
return
;;
2)
APP_KEY="recipes"
APP_LABEL="recipes"
PROJECT_PATH="$ROOT_DIR/recipes/recipesIosApp/recipesIosApp.xcodeproj"
SCHEME="recipesIosApp"
DERIVED_DATA_PATH="/tmp/app-platform-ios-run-recipes"
return
;;
3)
APP_KEY="starter"
APP_LABEL="starter blueprint"
PROJECT_PATH="$ROOT_DIR/blueprints/starter/iosApp/iosApp.xcodeproj"
SCHEME="iosApp"
DERIVED_DATA_PATH="/tmp/app-platform-ios-run-starter"
return
;;
*)
echo "Invalid selection."
;;
esac
done
}
load_simulators() {
local mode="$1"
local current_runtime=""
local line=""
local parsed_line=""
local device_name=""
local device_udid=""
local device_state=""
SIMULATOR_NAMES=()
SIMULATOR_UDIDS=()
SIMULATOR_STATES=()
SIMULATOR_RUNTIMES=()
if [[ "$mode" == "available" ]]; then
while IFS= read -r line; do
parsed_line="$(printf '%s\n' "$line" | sed -nE 's/^-- (.+) --$/\1/p')"
if [[ -n "$parsed_line" ]]; then
current_runtime="$parsed_line"
continue
fi
if [[ "$current_runtime" != iOS* ]]; then
continue
fi
parsed_line="$(printf '%s\n' "$line" | sed -nE 's/^[[:space:]]+(.+) \(([A-F0-9-]+)\) \(([^)]+)\)[[:space:]]*$/\1\t\2\t\3/p')"
if [[ -n "$parsed_line" ]]; then
IFS=$'\t' read -r device_name device_udid device_state <<<"$parsed_line"
SIMULATOR_NAMES+=("$device_name")
SIMULATOR_UDIDS+=("$device_udid")
SIMULATOR_STATES+=("$device_state")
SIMULATOR_RUNTIMES+=("$current_runtime")
fi
done < <(xcrun simctl list devices available)
else
while IFS= read -r line; do
parsed_line="$(printf '%s\n' "$line" | sed -nE 's/^-- (.+) --$/\1/p')"
if [[ -n "$parsed_line" ]]; then
current_runtime="$parsed_line"
continue
fi
if [[ "$current_runtime" != iOS* ]]; then
continue
fi
parsed_line="$(printf '%s\n' "$line" | sed -nE 's/^[[:space:]]+(.+) \(([A-F0-9-]+)\) \(([^)]+)\)[[:space:]]*$/\1\t\2\t\3/p')"
if [[ -n "$parsed_line" ]]; then
IFS=$'\t' read -r device_name device_udid device_state <<<"$parsed_line"
if [[ "$device_state" != "Booted" ]]; then
continue
fi
SIMULATOR_NAMES+=("$device_name")
SIMULATOR_UDIDS+=("$device_udid")
SIMULATOR_STATES+=("$device_state")
SIMULATOR_RUNTIMES+=("$current_runtime")
fi
done < <(xcrun simctl list devices)
fi
}
prompt_for_simulator_index() {
local prompt="$1"
local max_index="${#SIMULATOR_NAMES[@]}"
local i=""
local selection=""
if (( max_index == 0 )); then
echo "No matching simulators found." >&2
exit 1
fi
echo "$prompt" >&2
for (( i = 0; i < max_index; i++ )); do
printf "%d) %s [%s] (%s)\n" \
"$((i + 1))" \
"${SIMULATOR_NAMES[$i]}" \
"${SIMULATOR_RUNTIMES[$i]}" \
"${SIMULATOR_STATES[$i]}" >&2
done
while true; do
printf "Enter selection [1-%d]: " "$max_index" >&2
read -r selection
if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= max_index )); then
PROMPT_SELECTION_INDEX="$((selection - 1))"
return
fi
echo "Invalid selection." >&2
done
}
choose_simulator() {
local selected_udid=""
load_simulators "booted"
case "${#SIMULATOR_UDIDS[@]}" in
0)
load_simulators "available"
prompt_for_simulator_index "No simulator is booted. Which simulator should be booted?"
selected_udid="${SIMULATOR_UDIDS[$PROMPT_SELECTION_INDEX]}"
echo "Booting ${SIMULATOR_NAMES[$PROMPT_SELECTION_INDEX]}..." >&2
xcrun simctl boot "$selected_udid"
open -a Simulator --args -CurrentDeviceUDID "$selected_udid" >/dev/null 2>&1 || open -a Simulator >/dev/null 2>&1 || true
xcrun simctl bootstatus "$selected_udid" -b
SELECTED_SIMULATOR_UDID="$selected_udid"
;;
1)
echo "Using booted simulator: ${SIMULATOR_NAMES[0]} [${SIMULATOR_RUNTIMES[0]}]" >&2
SELECTED_SIMULATOR_UDID="${SIMULATOR_UDIDS[0]}"
;;
*)
prompt_for_simulator_index "More than one simulator is booted. Which one should be used?"
SELECTED_SIMULATOR_UDID="${SIMULATOR_UDIDS[$PROMPT_SELECTION_INDEX]}"
;;
esac
}
find_built_app() {
local products_dir="$1/Build/Products/Debug-iphonesimulator"
find "$products_dir" -maxdepth 1 -type d -name '*.app' | head -n 1
}
main() {
local app_path=""
local bundle_id=""
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "This script requires macOS." >&2
exit 1
fi
require_command xcodebuild
require_command xcrun
require_command open
require_command /usr/libexec/PlistBuddy
choose_application
choose_simulator
echo "Building $APP_LABEL for simulator $SELECTED_SIMULATOR_UDID..."
xcodebuild \
-project "$PROJECT_PATH" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "platform=iOS Simulator,id=$SELECTED_SIMULATOR_UDID" \
-derivedDataPath "$DERIVED_DATA_PATH" \
build
app_path="$(find_built_app "$DERIVED_DATA_PATH")"
if [[ -z "$app_path" ]]; then
echo "Could not find the built .app bundle in $DERIVED_DATA_PATH." >&2
exit 1
fi
bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app_path/Info.plist")"
echo "Installing $app_path..."
xcrun simctl install "$SELECTED_SIMULATOR_UDID" "$app_path"
echo "Launching $bundle_id..."
xcrun simctl launch "$SELECTED_SIMULATOR_UDID" "$bundle_id"
}
main "$@"
================================================
FILE: kotlin-inject/impl/api/android/impl.api
================================================
public abstract interface class software/amazon/app/platform/presenter/PresenterCoroutineScopeComponent {
public fun providePresenterCoroutineScope (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope;
}
public final class software/amazon/app/platform/presenter/PresenterCoroutineScopeComponent$DefaultImpls {
public static fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/PresenterCoroutineScopeComponent;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope;
}
public abstract interface class software/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent {
public fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope;
public fun provideAppScopeCoroutineScopeScoped (Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;
}
public final class software/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent$DefaultImpls {
public static fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope;
public static fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;
}
public abstract interface class software/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent {
public fun provideDefaultCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher;
public fun provideIoCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher;
public fun provideMainCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher;
}
public final class software/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent$DefaultImpls {
public static fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent;)Lkotlinx/coroutines/CoroutineDispatcher;
public static fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent;)Lkotlinx/coroutines/CoroutineDispatcher;
public static fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent;)Lkotlinx/coroutines/CoroutineDispatcher;
}
================================================
FILE: kotlin-inject/impl/api/desktop/impl.api
================================================
public abstract interface class software/amazon/app/platform/presenter/PresenterCoroutineScopeComponent {
public fun providePresenterCoroutineScope (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope;
}
public final class software/amazon/app/platform/presenter/PresenterCoroutineScopeComponent$DefaultImpls {
public static fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/PresenterCoroutineScopeComponent;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope;
}
public abstract interface class software/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent {
public fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope;
public fun provideAppScopeCoroutineScopeScoped (Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;
}
public final class software/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent$DefaultImpls {
public static fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope;
public static fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;
}
public abstract interface class software/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent {
public fun provideDefaultCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher;
public fun provideIoCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher;
public fun provideMainCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher;
}
public final class software/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent$DefaultImpls {
public static fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent;)Lkotlinx/coroutines/CoroutineDispatcher;
public static fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent;)Lkotlinx/coroutines/CoroutineDispatcher;
public static fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent;)Lkotlinx/coroutines/CoroutineDispatcher;
}
================================================
FILE: kotlin-inject/impl/build.gradle
================================================
plugins {
id 'software.amazon.app.platform.lib'
}
appPlatformBuildSrc {
enableKotlinInject true
enablePublishing true
}
dependencies {
commonMainApi project(':scope:public')
}
================================================
FILE: kotlin-inject/impl/src/commonMain/kotlin/software/amazon/app/platform/presenter/PresenterCoroutineScopeComponent.kt
================================================
package software.amazon.app.platform.presenter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.plus
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.scope.coroutine.MainCoroutineDispatcher
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
/** Provides the coroutine scope to run presenters. */
@ContributesTo(AppScope::class)
public interface PresenterCoroutineScopeComponent {
/**
* Bind the app coroutine scope as default scope for presenters to allow them to run as long as
* the app is alive. The coroutine scope will use the main dispatcher by default, because
* presenters produce state for the UI and computing their models should have the highest
* priority.
*/
@Provides
@PresenterCoroutineScope
public fun providePresenterCoroutineScope(
@ForScope(AppScope::class) scope: CoroutineScope,
@MainCoroutineDispatcher mainDispatcher: CoroutineDispatcher,
): CoroutineScope = scope + mainDispatcher
}
================================================
FILE: kotlin-inject/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/AppScopeCoroutineScopeComponent.kt
================================================
package software.amazon.app.platform.scope.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
/** Component providing coroutine scopes in the App scope. */
@ContributesTo(AppScope::class)
public interface AppScopeCoroutineScopeComponent {
/**
* Provides the [CoroutineScopeScoped] for the app scope. This is a single instance for the app
* scope.
*/
@Provides
@SingleIn(AppScope::class)
@ForScope(AppScope::class)
public fun provideAppScopeCoroutineScopeScoped(
@IoCoroutineDispatcher dispatcher: CoroutineDispatcher
): CoroutineScopeScoped {
return CoroutineScopeScoped(dispatcher + SupervisorJob() + CoroutineName("AppScope"))
}
/**
* Provides the [CoroutineScope] for the app scope. A new child scope is created every time an
* instance is injected so that the parent cannot be canceled accidentally.
*/
@Provides
@ForScope(AppScope::class)
public fun provideAppCoroutineScope(
@ForScope(AppScope::class) appScopeCoroutineScopeScoped: CoroutineScopeScoped
): CoroutineScope {
return appScopeCoroutineScopeScoped.createChild()
}
}
================================================
FILE: kotlin-inject/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/CoroutineDispatcherComponent.kt
================================================
package software.amazon.app.platform.scope.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
/** Provides default dispatchers for Coroutine scopes. */
@ContributesTo(AppScope::class)
public interface CoroutineDispatcherComponent {
/** Provides the IO dispatcher in the dependency graph. */
@Provides
@IoCoroutineDispatcher
public fun provideIoCoroutineDispatcher(): CoroutineDispatcher = ioDispatcher
/** Provides the default dispatcher in the dependency graph. */
@Provides
@DefaultCoroutineDispatcher
public fun provideDefaultCoroutineDispatcher(): CoroutineDispatcher = Dispatchers.Default
/** Provides the main dispatcher in the dependency graph. */
@Provides
@MainCoroutineDispatcher
public fun provideMainCoroutineDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
}
================================================
FILE: kotlin-inject/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/IoDispatcher.kt
================================================
package software.amazon.app.platform.scope.coroutine
import kotlinx.coroutines.CoroutineDispatcher
/** Expect declaration for the IO dispatcher, because it doesn't exist for WASM. */
internal expect val ioDispatcher: CoroutineDispatcher
================================================
FILE: kotlin-inject/impl/src/noWasmJsMain/kotlin/software/amazon/app/platform/scope/coroutine/IoDispatcher.kt
================================================
package software.amazon.app.platform.scope.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
/** Expect declaration for the IO dispatcher, because it doesn't exist for WASM. */
internal actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
================================================
FILE: kotlin-inject/impl/src/wasmJsMain/kotlin/software/amazon/app/platform/scope/coroutine/IoDispatcher.kt
================================================
package software.amazon.app.platform.scope.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
/** Expect declaration for the IO dispatcher, because it doesn't exist for WASM. */
// Fallback to the Default dispatcher.
internal actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
================================================
FILE: kotlin-inject/public/api/android/public.api
================================================
public final class software/amazon/app/platform/scope/di/ComponentServiceKt {
public static final field DI_COMPONENT_KEY Ljava/lang/String;
public static final fun addDiComponent (Lsoftware/amazon/app/platform/scope/Scope$Builder;Ljava/lang/Object;)V
public static final fun addKotlinInjectComponent (Lsoftware/amazon/app/platform/scope/Scope$Builder;Ljava/lang/Object;)V
}
================================================
FILE: kotlin-inject/public/api/desktop/public.api
================================================
public final class software/amazon/app/platform/scope/di/ComponentServiceKt {
public static final field DI_COMPONENT_KEY Ljava/lang/String;
public static final fun addDiComponent (Lsoftware/amazon/app/platform/scope/Scope$Builder;Ljava/lang/Object;)V
public static final fun addKotlinInjectComponent (Lsoftware/amazon/app/platform/scope/Scope$Builder;Ljava/lang/Object;)V
}
================================================
FILE: kotlin-inject/public/build.gradle
================================================
plugins {
id 'software.amazon.app.platform.lib'
}
appPlatformBuildSrc {
enablePublishing true
}
dependencies {
commonMainApi project(':scope:public')
commonTestImplementation project(':internal:testing')
}
================================================
FILE: kotlin-inject/public/src/commonMain/kotlin/software/amazon/app/platform/scope/di/ComponentService.kt
================================================
package software.amazon.app.platform.scope.di
import software.amazon.app.platform.scope.Scope
import software.amazon.app.platform.scope.parents
@PublishedApi internal const val DI_COMPONENT_KEY: String = "diComponent"
/** This function is deprecated. [kotlinInjectComponent] is a one to one replacement. */
@Deprecated(
message = "Use kotlinInjectComponent instead.",
replaceWith = ReplaceWith("kotlinInjectComponent()"),
level = DeprecationLevel.WARNING,
)
public inline fun Scope.diComponent(): T = kotlinInjectComponent()
/**
* Provides the DI component that has been added to this [Scope]. A common pattern is to use this
* function to look up component interfaces in static contexts like test methods, static functions
* or where constructor injection cannot be used, e.g.
*
* ```
* interface HudComponent {
* fun hudManager(): HudManager
* }
*
* rootScope.diComponent().hudManager()
* ```
*
* The given component type [T] of the DI component can be provided by this scope or a parent scope.
*/
public inline fun Scope.kotlinInjectComponent(): T {
parents(includeSelf = true)
.firstNotNullOfOrNull { scope -> scope.getService(DI_COMPONENT_KEY) as? T }
?.let {
return it
}
val diComponents =
parents(includeSelf = true)
.map { it.getService(DI_COMPONENT_KEY) }
.filterNotNull()
.map { it::class }
// The replace() will align inner class references across platforms. Native uses a '.',
// whereas the JVM platform use '$'.
throw NoSuchElementException(
"Couldn't find component implementing ${T::class}. Inspected: " +
"[${diComponents.joinToString { it.simpleName.toString() }}] (fully qualified " +
"names: [${diComponents.joinToString { it.toString().replace('\$', '.') }}])"
)
}
/** This function is deprecated. [addKotlinInjectComponent] is a one to one replacement. */
@Deprecated(
message = "Use addKotlinInjectComponent instead.",
replaceWith = ReplaceWith("addKotlinInjectComponent(component)"),
level = DeprecationLevel.WARNING,
)
public fun Scope.Builder.addDiComponent(component: Any) {
addKotlinInjectComponent(component)
}
/**
* Adds the given [component] to this builder. The instance can be later retrieved with
* [kotlinInjectComponent].
*/
public fun Scope.Builder.addKotlinInjectComponent(component: Any) {
addService(DI_COMPONENT_KEY, component)
}
================================================
FILE: kotlin-inject/public/src/commonTest/kotlin/software/amazon/app/platform/scope/di/ComponentServiceTest.kt
================================================
package software.amazon.app.platform.scope.di
import assertk.assertThat
import assertk.assertions.hasMessage
import assertk.assertions.isSameInstanceAs
import kotlin.test.Test
import kotlin.test.assertFailsWith
import software.amazon.app.platform.internal.IgnoreWasm
import software.amazon.app.platform.internal.Platform
import software.amazon.app.platform.internal.platform
import software.amazon.app.platform.scope.Scope
class ComponentServiceTest {
@Test
fun `a DI component can be registered in a scope`() {
val component = ParentComponentImpl()
val scope = Scope.buildRootScope { addKotlinInjectComponent(component) }
assertThat(scope.kotlinInjectComponent()).isSameInstanceAs(component)
}
@Test
@IgnoreWasm
fun `if a DI component cannot be found then an exception is thrown with a helpful error message`() {
val parentComponent = ParentComponentImpl()
val childComponent = ChildComponentImpl()
val parentScope = Scope.buildRootScope { addKotlinInjectComponent(parentComponent) }
val childScope = parentScope.buildChild("child") { addKotlinInjectComponent(childComponent) }
val exception =
assertFailsWith { childScope.kotlinInjectComponent() }
val kotlinReflectWarning =
when (platform) {
Platform.JVM -> " (Kotlin reflection is not available)"
Platform.Native,
Platform.Web -> ""
}
assertThat(exception)
.hasMessage(
"Couldn't find component implementing class kotlin.Unit$kotlinReflectWarning. " +
"Inspected: [ChildComponentImpl, ParentComponentImpl] (fully qualified names: " +
"[class software.amazon.app.platform.scope.di.ComponentServiceTest." +
"ChildComponentImpl$kotlinReflectWarning, class software.amazon.app." +
"platform.scope.di.ComponentServiceTest.ParentComponentImpl" +
"$kotlinReflectWarning])"
)
}
@Test
fun `a DI component can be retrieved from a scope`() {
val parentComponent = ParentComponentImpl()
val childComponent = ChildComponentImpl()
val parentScope = Scope.buildRootScope { addKotlinInjectComponent(parentComponent) }
val childScope = parentScope.buildChild("child") { addKotlinInjectComponent(childComponent) }
assertThat(childScope.kotlinInjectComponent()).isSameInstanceAs(childComponent)
assertThat(childScope.kotlinInjectComponent())
.isSameInstanceAs(parentComponent)
assertThat(parentScope.kotlinInjectComponent())
.isSameInstanceAs(parentComponent)
assertFailsWith { parentScope.kotlinInjectComponent() }
}
private interface ParentComponent
private class ParentComponentImpl : ParentComponent
private interface ChildComponent
private class ChildComponentImpl : ChildComponent
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/build.gradle
================================================
plugins {
id 'software.amazon.app.platform.lib.jvm'
id 'com.google.devtools.ksp'
alias(libs.plugins.build.config)
}
appPlatformBuildSrc {
enablePublishing true
}
test {
useJUnitPlatform()
// Since Kotlin 2.0 we need more memory to run our tests.
maxHeapSize = "2g"
}
dependencies {
implementation project(':ksp-common:public')
implementation libs.ksp.api
implementation libs.kotlin.poet
implementation libs.kotlin.poet.ksp
implementation libs.auto.service.annotations
ksp libs.auto.service.ksp
// Gives us access to annotations.
implementation project(':di-common:public')
implementation project(':scope:public')
implementation libs.kotlin.inject.runtime
implementation libs.kotlin.inject.anvil.runtime
implementation libs.kotlin.inject.anvil.runtime.optional
testImplementation project(':kotlin-inject:public')
testImplementation project(':ksp-common:testing')
testImplementation project(':presenter:public')
testImplementation project(':renderer:public')
testImplementation project(':robot:public')
testImplementation libs.kotlin.compile.testing.core
testImplementation libs.kotlin.compile.testing.ksp
// Added so that the SymbolProcessor is picked up in tests.
testImplementation libs.kotlin.inject.ksp
testImplementation libs.kotlin.inject.anvil.compiler
// Bump transitive dependency.
testImplementation libs.kotlin.compiler.embeddable
testImplementation libs.ksp
testImplementation libs.ksp.embeddable
}
buildConfig {
useKotlinOutput {
internalVisibility = false
}
}
// We don't need the apiCheck in this module.
tasks.named('apiCheck').configure {
it.enabled = false
}
tasks.named('apiDump').configure {
it.enabled = false
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/KotlinInjectContextAware.kt
================================================
package software.amazon.app.platform.inject
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSType
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Scope
import software.amazon.app.platform.ksp.ContextAware
@Suppress("TooManyFunctions")
internal interface KotlinInjectContextAware : ContextAware {
val injectFqName
get() = Inject::class.requireQualifiedName()
private val scopeFqName
get() = Scope::class.requireQualifiedName()
fun KSAnnotation.isKotlinInjectScopeAnnotation(): Boolean {
return annotationType.resolve().isKotlinInjectScopeAnnotation()
}
private fun KSType.isKotlinInjectScopeAnnotation(): Boolean {
return declaration.annotations.any {
it.annotationType.resolve().declaration.requireQualifiedName() == scopeFqName
}
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/KotlinInjectExtensionSymbolProcessorProvider.kt
================================================
package software.amazon.app.platform.inject
import com.google.auto.service.AutoService
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import software.amazon.app.platform.inject.processor.ContributesBindingProcessor
import software.amazon.app.platform.inject.processor.ContributesBindingScopedProcessor
import software.amazon.app.platform.inject.processor.ContributesMockImplProcessor
import software.amazon.app.platform.inject.processor.ContributesRealImplProcessor
import software.amazon.app.platform.inject.processor.ContributesRendererProcessor
import software.amazon.app.platform.inject.processor.ContributesRobotProcessor
import software.amazon.app.platform.ksp.CompositeSymbolProcessor
/** Entry point for KSP to pick up our [SymbolProcessor]. */
@AutoService(SymbolProcessorProvider::class)
@Suppress("unused")
public class KotlinInjectExtensionSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return CompositeSymbolProcessor(
ContributesBindingProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
ContributesBindingScopedProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
ContributesRendererProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
ContributesRealImplProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
ContributesMockImplProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
ContributesRobotProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
)
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/Util.kt
================================================
package software.amazon.app.platform.inject
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.squareup.kotlinpoet.Annotatable
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ksp.toClassName
import software.amazon.lastmile.kotlin.inject.anvil.internal.Origin
/**
* The package in which code is generated that should be picked up during the merging phase. This
* package is used by the open source project.
*/
internal const val OPEN_SOURCE_LOOKUP_PACKAGE = "amazon.lastmile.inject"
/** The package in which the App Platform extensions generate code. */
internal const val APP_PLATFORM_LOOKUP_PACKAGE = "app.platform.inject"
internal fun > Annotatable.Builder.addOriginAnnotation(
clazz: KSClassDeclaration
): T =
addAnnotation(
AnnotationSpec.builder(Origin::class)
.addMember("value = %T::class", clazz.toClassName())
.build()
)
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/processor/ContributesBindingProcessor.kt
================================================
package software.amazon.app.platform.inject.processor
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.writeTo
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.inject.KotlinInjectContextAware
import software.amazon.app.platform.inject.OPEN_SOURCE_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.addOriginAnnotation
import software.amazon.app.platform.ksp.argumentOfTypeAt
import software.amazon.app.platform.ksp.decapitalize
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
/**
* Generates the code for [ContributesBinding].
*
* In the lookup package [OPEN_SOURCE_LOOKUP_PACKAGE] a new interface is generated with a provider
* method for the annotated type. To avoid name clashes the package name of the original interface
* is encoded in the interface name. E.g.
*
* ```
* package software.amazon.test
*
* @Inject
* @SingleIn(AppScope::class)
* @ContributesBinding(AppScope::class)
* class RealAuthenticator : Authenticator
* ```
*
* Will generate:
* ```
* package $LOOKUP_PACKAGE
*
* @Origin(RealAuthenticator::class)
* interface SoftwareAmazonTestRealAuthenticator {
* @Provides fun provideRealAuthenticatorAuthenticator(
* realAuthenticator: RealAuthenticator
* ): Authenticator = realAuthenticator
* }
* ```
*/
internal class ContributesBindingProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, KotlinInjectContextAware {
override fun process(resolver: Resolver): List {
resolver
.getSymbolsWithAnnotation(ContributesBinding::class)
.filterIsInstance()
.onEach {
checkIsPublic(it)
checkHasScope(it)
}
.forEach { generateComponentInterface(it) }
return emptyList()
}
@Suppress("LongMethod")
private fun generateComponentInterface(clazz: KSClassDeclaration) {
val componentClassName = ClassName(OPEN_SOURCE_LOOKUP_PACKAGE, clazz.safeClassName)
val annotations = clazz.findAnnotationsAtLeastOne(ContributesBinding::class)
checkNoDuplicateBoundTypes(clazz, annotations)
val boundTypes =
annotations
.mapNotNull { annotation ->
val boundType =
boundType(clazz, annotation).takeUnless { it.isScoped() } ?: return@mapNotNull null
GeneratedFunction(
boundType = boundType,
multibinding = annotation.argumentOfTypeAt(this, "multibinding") ?: false,
)
}
.distinctBy { it.bindingMethodReturnType.canonicalName + it.multibinding }
// The only boundType was Scoped, which is handled by a separate processor.
if (boundTypes.isEmpty()) return
val fileSpec =
FileSpec.builder(componentClassName)
.addType(
TypeSpec.interfaceBuilder(componentClassName)
.addOriginatingKSFile(clazz.requireContainingFile())
.addOriginAnnotation(clazz)
.addFunctions(
boundTypes.map { function ->
val multibindingSuffix =
if (function.multibinding) {
"Multibinding"
} else {
""
}
FunSpec.builder(
"provide${clazz.innerClassNames()}" +
function.bindingMethodReturnType.simpleName +
multibindingSuffix
)
.addAnnotation(Provides::class)
.apply {
if (function.multibinding) {
addAnnotation(IntoSet::class)
}
}
.apply {
val parameterName = clazz.innerClassNames().decapitalize()
addParameter(
ParameterSpec.builder(name = parameterName, type = clazz.toClassName())
.build()
)
addStatement("return $parameterName")
}
.returns(function.bindingMethodReturnType)
.build()
}
)
.build()
)
.build()
fileSpec.writeTo(codeGenerator, aggregating = false)
}
private inner class GeneratedFunction(boundType: KSType, val multibinding: Boolean) {
val bindingMethodReturnType by lazy { boundType.toClassName() }
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/processor/ContributesBindingScopedProcessor.kt
================================================
package software.amazon.app.platform.inject.processor
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.writeTo
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.KotlinInjectContextAware
import software.amazon.app.platform.inject.OPEN_SOURCE_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.addOriginAnnotation
import software.amazon.app.platform.ksp.decapitalize
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
/**
* Generates the code for [ContributesBinding] and the `Scoped` type.
*
* In the lookup package [OPEN_SOURCE_LOOKUP_PACKAGE] a new interface is generated with a provider
* method for the annotated type. To avoid name clashes the package name of the original interface
* is encoded in the interface name. E.g.
*
* ```
* package software.amazon.test
*
* @Inject
* @SingleIn(AppScope::class)
* @ContributesBinding(AppScope::class)
* class RealAuthenticator : Authenticator, Scoped
* ```
*
* Will generate:
* ```
* package $LOOKUP_PACKAGE
*
* @Origin(RealAuthenticator::class)
* interface SoftwareAmazonTestRealAuthenticatorScoped {
* @Provides
* @IntoSet
* @ForScope(AppScope::class)
* fun provideRealAuthenticatorAuthenticatorScoped(
* realAuthenticator: RealAuthenticator
* ): Scoped = realAuthenticator
* }
* ```
*/
internal class ContributesBindingScopedProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, KotlinInjectContextAware {
override fun process(resolver: Resolver): List {
resolver
.getSymbolsWithAnnotation(ContributesBinding::class)
.filterIsInstance()
.filter { clazz ->
val hasSuperType = clazz.superTypes.any { it.resolve().isScoped() }
if (hasSuperType) return@filter true
val annotations = clazz.findAnnotationsAtLeastOne(ContributesBinding::class)
annotations.any { annotation -> boundType(clazz, annotation).isScoped() }
}
.onEach {
checkIsPublic(it)
checkHasScope(it)
}
.forEach { generateComponentInterface(it) }
return emptyList()
}
@Suppress("LongMethod")
private fun generateComponentInterface(clazz: KSClassDeclaration) {
val componentPackage = "${APP_PLATFORM_LOOKUP_PACKAGE}.${clazz.packageName.asString()}"
val componentClassName =
ClassName(componentPackage, "${clazz.innerClassNames()}ScopedComponent")
val scope = clazz.scope()
val fileSpec =
FileSpec.builder(componentClassName)
.addType(
TypeSpec.interfaceBuilder(componentClassName)
.addOriginatingKSFile(clazz.requireContainingFile())
.addOriginAnnotation(clazz)
.addAnnotation(
AnnotationSpec.builder(ContributesTo::class)
.addMember("scope = %T::class", scope.type.toClassName())
.build()
)
.addFunction(
FunSpec.builder("provide${clazz.innerClassNames()}Scoped")
.addAnnotation(Provides::class)
.addAnnotation(IntoSet::class)
.addAnnotation(
AnnotationSpec.builder(ForScope::class)
.addMember("scope = %T::class", scope.type.toClassName())
.build()
)
.apply {
val parameterName = clazz.innerClassNames().decapitalize()
addParameter(
ParameterSpec.builder(name = parameterName, type = clazz.toClassName()).build()
)
addStatement("return $parameterName")
}
.returns(scopedClassName)
.build()
)
.build()
)
.build()
fileSpec.writeTo(codeGenerator, aggregating = false)
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/processor/ContributesMockImplProcessor.kt
================================================
package software.amazon.app.platform.inject.processor
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.isAnnotationPresent
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.LambdaTypeName
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.writeTo
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.KotlinInjectContextAware
import software.amazon.app.platform.inject.addOriginAnnotation
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.inject.mock.RealImpl
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
/**
* Generates the necessary code in order to support [ContributesMockImpl].
*
* If the class implements `Scoped`, then based on the mock mode flag the mock implementation gets
* called or not.
*
* ```
* package app.platform.inject.software.amazon.test
*
* @ContributesTo(scope = AppScope::class)
* public interface MockVtsMockImplComponent {
* @Provides
* public fun provideVts(
* @MockMode mockMode: Boolean,
* mockImpl: () -> MockVts,
* @RealImpl realImpl: () -> Vts,
* ): Vts = if (mockMode) mockImpl() else realImpl()
*
* @Provides
* @IntoSet
* @ForScope(AppScope::class)
* fun provideMockVtsScoped(
* @MockMode mockMode: Boolean,
* mockImpl: () -> MockVts,
* ): Scoped = if (mockMode) mockImpl() else Scoped.NO_OP
* }
* ```
*/
internal class ContributesMockImplProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, KotlinInjectContextAware {
override fun process(resolver: Resolver): List {
resolver
.getSymbolsWithAnnotation(ContributesMockImpl::class)
.filterIsInstance()
.onEach { checkIsPublic(it) }
.forEach { generateComponentInterface(it) }
return emptyList()
}
@OptIn(KspExperimental::class)
@Suppress("LongMethod")
private fun generateComponentInterface(clazz: KSClassDeclaration) {
val packageName = "${APP_PLATFORM_LOOKUP_PACKAGE}.${clazz.packageName.asString()}"
val componentClassName = ClassName(packageName, "${clazz.innerClassNames()}MockImplComponent")
val annotations = clazz.findAnnotationsAtLeastOne(ContributesMockImpl::class)
checkNoDuplicateBoundTypes(clazz, annotations)
val fileSpec =
FileSpec.builder(componentClassName)
.addType(
TypeSpec.interfaceBuilder(componentClassName)
.addOriginatingKSFile(clazz.requireContainingFile())
.addOriginAnnotation(clazz)
.addAnnotation(
AnnotationSpec.builder(ContributesTo::class)
.addMember("%T::class", clazz.scope().type.toClassName())
.build()
)
.addFunctions(
annotations.map { annotation ->
val boundType = boundType(clazz, annotation)
check(!boundType.isScoped(), clazz) { "Scoped cannot be used as bound type." }
FunSpec.builder("provide${boundType.declaration.simpleName.asString()}")
.addAnnotation(Provides::class)
.addParameter(
ParameterSpec.builder("mockMode", Boolean::class)
.addAnnotation(MockMode::class)
.build()
)
.addParameter("mockImpl", LambdaTypeName.get(returnType = clazz.toClassName()))
.addParameter(
ParameterSpec.builder(
"realImpl",
LambdaTypeName.get(returnType = boundType.toClassName()),
)
.addAnnotation(RealImpl::class)
.build()
)
.returns(boundType.toClassName())
.addStatement("return if (mockMode) mockImpl() else realImpl()")
.build()
}
)
.apply {
if (
clazz.superTypes.any { it.resolve().isScoped() } &&
!clazz.isAnnotationPresent(ContributesBinding::class)
) {
addFunction(
FunSpec.builder("provide${clazz.innerClassNames()}Scoped")
.addAnnotation(Provides::class)
.addAnnotation(IntoSet::class)
.addAnnotation(
AnnotationSpec.builder(ForScope::class)
.addMember("scope = %T::class", clazz.scope().type.toClassName())
.build()
)
.addParameter(
ParameterSpec.builder("mockMode", Boolean::class)
.addAnnotation(MockMode::class)
.build()
)
.addParameter("mockImpl", LambdaTypeName.get(returnType = clazz.toClassName()))
.returns(scopedClassName)
.addStatement("return if (mockMode) mockImpl() else %T.NO_OP", scopedClassName)
.build()
)
}
}
.build()
)
.build()
fileSpec.writeTo(codeGenerator, aggregating = false)
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/processor/ContributesRealImplProcessor.kt
================================================
package software.amazon.app.platform.inject.processor
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.isAnnotationPresent
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.LambdaTypeName
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.writeTo
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.KotlinInjectContextAware
import software.amazon.app.platform.inject.addOriginAnnotation
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.inject.mock.RealImpl
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
/**
* Generates the necessary code in order to support [ContributesRealImpl].
*
* If the class implements `Scoped`, then based on the mock mode flag the real implementation gets
* called or not.
*
* ```
* package app.platform.inject.software.amazon.test
*
* @ContributesTo(scope = AppScope::class)
* public interface RealVtsRealImplComponent {
* @Provides
* @RealImpl
* public fun provideVtsRealImpl(realImpl: RealVts): Vts = realVts
*
* @Provides
* @IntoSet
* @ForScope(AppScope::class)
* fun provideVtsRealImplScoped(
* @MockMode mockMode: Boolean,
* realImpl: () -> RealVts,
* ): Scoped = if (mockMode) Scoped.NO_OP else realImpl()
* }
* ```
*/
internal class ContributesRealImplProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, KotlinInjectContextAware {
override fun process(resolver: Resolver): List {
resolver
.getSymbolsWithAnnotation(ContributesRealImpl::class)
.filterIsInstance()
.onEach { checkIsPublic(it) }
.forEach { generateComponentInterface(it) }
return emptyList()
}
@OptIn(KspExperimental::class)
@Suppress("LongMethod")
private fun generateComponentInterface(clazz: KSClassDeclaration) {
val packageName = "${APP_PLATFORM_LOOKUP_PACKAGE}.${clazz.packageName.asString()}"
val componentClassName = ClassName(packageName, "${clazz.innerClassNames()}RealImplComponent")
val annotations = clazz.findAnnotationsAtLeastOne(ContributesRealImpl::class)
checkNoDuplicateBoundTypes(clazz, annotations)
val fileSpec =
FileSpec.builder(componentClassName)
.addType(
TypeSpec.interfaceBuilder(componentClassName)
.addOriginatingKSFile(clazz.requireContainingFile())
.addOriginAnnotation(clazz)
.addAnnotation(
AnnotationSpec.builder(ContributesTo::class)
.addMember("%T::class", clazz.scope().type.toClassName())
.build()
)
.addFunctions(
annotations.map { annotation ->
val boundType = boundType(clazz, annotation)
check(!boundType.isScoped(), clazz) { "Scoped cannot be used as bound type." }
FunSpec.builder(
"provide${boundType.declaration.simpleName.asString()}" + "RealImpl"
)
.addAnnotation(Provides::class)
.addAnnotation(RealImpl::class)
.addParameter("realImpl", clazz.toClassName())
.returns(boundType.toClassName())
.addStatement("return realImpl")
.build()
}
)
.apply {
if (
clazz.superTypes.any { it.resolve().isScoped() } &&
!clazz.isAnnotationPresent(ContributesBinding::class)
) {
addFunction(
FunSpec.builder("provide${clazz.innerClassNames()}Scoped")
.addAnnotation(Provides::class)
.addAnnotation(IntoSet::class)
.addAnnotation(
AnnotationSpec.builder(ForScope::class)
.addMember("scope = %T::class", clazz.scope().type.toClassName())
.build()
)
.addParameter(
ParameterSpec.builder("mockMode", Boolean::class)
.addAnnotation(MockMode::class)
.build()
)
.addParameter("realImpl", LambdaTypeName.get(returnType = clazz.toClassName()))
.returns(scopedClassName)
.addStatement("return if (mockMode) %T.NO_OP else realImpl()", scopedClassName)
.build()
)
}
}
.build()
)
.build()
fileSpec.writeTo(codeGenerator, aggregating = false)
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/processor/ContributesRendererProcessor.kt
================================================
package software.amazon.app.platform.inject.processor
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.getAllSuperTypes
import com.google.devtools.ksp.getAnnotationsByType
import com.google.devtools.ksp.isAnnotationPresent
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.LambdaTypeName
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.STAR
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.WildcardTypeName
import com.squareup.kotlinpoet.asClassName
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.writeTo
import kotlin.reflect.KClass
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.IntoMap
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.inject.KotlinInjectContextAware
import software.amazon.app.platform.inject.addOriginAnnotation
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
/**
* Generates the code for [ContributesRenderer].
*
* In the lookup package [APP_PLATFORM_LOOKUP_PACKAGE] a new interface is generated with a provider
* method for the renderer, e.g.
*
* ```
* package software.amazon.test
*
* @ContributesRenderer
* class TestRenderer : Renderer
* ```
*
* Will generate:
* ```
* package $APP_PLATFORM_LOOKUP_PACKAGE.software.amazon.test
*
* @ContributesTo(RendererScope::class)
* @Origin(TestRenderer::class)
* interface TestRendererComponent {
* @Provides
* @IntoMap
* fun provideTestRendererIntoMap(
* renderer: () -> TestRenderer,
* ): Pair, () -> Renderer<*>> = Model::class to renderer
*
* @Provides
* fun provideTestRenderer(): TestRenderer = TestRenderer()
*
* @Provides
* @IntoMap
* @ForScope(RendererScope::class)
* fun provideRendererModelKey(): Pair, KClass>> =
* Model::class to TestRenderer::class
* }
* ```
*/
internal class ContributesRendererProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, KotlinInjectContextAware {
private val baseModel = ClassName("software.amazon.app.platform.presenter", "BaseModel")
private val baseModelFqName = baseModel.canonicalName
private val rendererWildcard =
ClassName("software.amazon.app.platform.renderer", "Renderer").parameterizedBy(STAR)
private val rendererScope = ClassName("software.amazon.app.platform.renderer", "RendererScope")
private val singleIn = SingleIn::class.asClassName()
private val unitFqName = Unit::class.requireQualifiedName()
override fun process(resolver: Resolver): List {
resolver
.getSymbolsWithAnnotation(ContributesRenderer::class)
.filterIsInstance()
.onEach {
checkIsPublic(it)
checkNoSingleton(it)
}
.forEach { generateComponentInterface(it) }
return emptyList()
}
@OptIn(KspExperimental::class)
private fun generateComponentInterface(clazz: KSClassDeclaration) {
val packageName = "${APP_PLATFORM_LOOKUP_PACKAGE}.${clazz.packageName.asString()}"
val componentClassName = ClassName(packageName, "${clazz.innerClassNames()}Component")
val hasInjectAnnotation = clazz.isAnnotationPresent(Inject::class)
if (hasInjectAnnotation) {
checkNoZeroArgConstructor(clazz)
} else {
checkZeroArgConstructor(clazz)
}
val includeSealedSubtypes =
try {
clazz.getAnnotationsByType(ContributesRenderer::class).single().includeSealedSubtypes
} catch (_: NoSuchElementException) {
/*
Caused by: java.util.NoSuchElementException: Collection contains no element matching the predicate.
at com.google.devtools.ksp.UtilsKt.createInvocationHandler$lambda$8(utils.kt:591)
at jdk.proxy105/jdk.proxy105.$Proxy1029.includeSealedSubtypes(Unknown Source)
at software.amazon.app.platform.inject.processor.ContributesRendererProcessor.generateComponentInterface(ContributesRendererProcessor.kt:120)
We're seeing this exception when trying to read 'includeSealedSubtypes' for an annotation
where the value is not declared, e.g. '@ContributesRenderer' (without any arguments).
This happens only on iOS for some reason. Fallback to the default value 'true'.
*/
true
}
val allModels =
if (includeSealedSubtypes) {
generateSequence(listOf(modelType(clazz))) { classes ->
classes.flatMap { it.getSealedSubclasses() }.takeIf { it.isNotEmpty() }
}
.flatten()
} else {
sequenceOf(modelType(clazz))
}
val fileSpec =
FileSpec.builder(componentClassName)
.addType(
TypeSpec.interfaceBuilder(componentClassName)
.addOriginatingKSFile(clazz.requireContainingFile())
.addOriginAnnotation(clazz)
.addAnnotation(
AnnotationSpec.builder(ContributesTo::class)
.addMember("%T::class", rendererScope)
.build()
)
.apply {
if (!hasInjectAnnotation) {
addFunction(
FunSpec.builder("provide${clazz.safeClassName}")
.addAnnotation(Provides::class)
.returns(clazz.toClassName())
.addStatement("return %T()", clazz.toClassName())
.build()
)
}
}
.addFunctions(allModels.map { createModelBindingFunction(clazz, it) }.toList())
.addFunctions(allModels.map { createModelKeyFunction(clazz, it) }.toList())
.build()
)
.build()
fileSpec.writeTo(codeGenerator, aggregating = false)
}
private fun modelType(clazz: KSClassDeclaration): KSClassDeclaration {
val annotation = clazz.findAnnotation(ContributesRenderer::class)
val explicitModelType =
(annotation.arguments.firstOrNull { it.name?.asString() == "modelType" }
?: annotation.arguments.firstOrNull())
?.let { (it.value as? KSType)?.declaration as? KSClassDeclaration }
?.takeIf { it.requireQualifiedName() != unitFqName }
if (explicitModelType != null) {
return explicitModelType
}
val implicitModelTypes =
clazz
.getAllSuperTypes()
.flatMap { superType ->
superType.arguments.filter { it.type?.resolve()?.extendsBaseModel() ?: false }
}
.mapNotNull { it.type?.resolve()?.declaration as? KSClassDeclaration }
.distinctBy { it.requireQualifiedName() }
.toList()
check(implicitModelTypes.size == 1, clazz) {
buildString {
append(
"Couldn't find BaseModel type for ${clazz.simpleName.asString()}. " +
"Consider adding an explicit parameter."
)
if (implicitModelTypes.size > 1) {
append("Found: ")
append(implicitModelTypes.joinToString { it.requireQualifiedName() })
}
}
}
return implicitModelTypes[0]
}
private fun createModelBindingFunction(
clazz: KSClassDeclaration,
modelType: KSClassDeclaration,
): FunSpec {
return FunSpec.builder("provide${clazz.safeClassName}" + modelType.innerClassNames())
.addAnnotation(Provides::class)
.addAnnotation(IntoMap::class)
.addParameter(name = "renderer", type = LambdaTypeName.get(returnType = clazz.toClassName()))
.returns(
Pair::class.asClassName()
.parameterizedBy(
listOf(
KClass::class.asClassName().parameterizedBy(WildcardTypeName.producerOf(baseModel)),
LambdaTypeName.get(returnType = rendererWildcard),
)
)
)
.addStatement("return %T::class·to·renderer", modelType.toClassName())
.build()
}
private fun createModelKeyFunction(
clazz: KSClassDeclaration,
modelType: KSClassDeclaration,
): FunSpec {
return FunSpec.builder("provide${clazz.safeClassName}" + modelType.innerClassNames() + "Key")
.addAnnotation(Provides::class)
.addAnnotation(IntoMap::class)
.addAnnotation(
AnnotationSpec.builder(ForScope::class)
.addMember("scope = %T::class", rendererScope)
.build()
)
.returns(
Pair::class.asClassName()
.parameterizedBy(
listOf(
KClass::class.asClassName().parameterizedBy(WildcardTypeName.producerOf(baseModel)),
KClass::class.asClassName()
.parameterizedBy(WildcardTypeName.producerOf(rendererWildcard)),
)
)
)
.addStatement("return %T::class·to·%T::class", modelType.toClassName(), clazz.toClassName())
.build()
}
private fun checkNoSingleton(clazz: KSClassDeclaration) {
val hasSingleInAnnotation =
clazz.annotations.any { annotation ->
annotation.isAnnotation(singleIn.canonicalName) &&
clazz.scope().type.declaration.requireQualifiedName() == rendererScope.canonicalName
}
if (hasSingleInAnnotation) {
logger.error(
"Renderers should not be singletons in the RendererScope. The " +
"RendererFactory will cache the Renderer when necessary. Remove the " +
"@SingleIn(RendererScope::class) annotation.",
clazz,
)
}
}
private fun checkNoZeroArgConstructor(clazz: KSClassDeclaration) {
val parameterCount = clazz.primaryConstructor?.parameters?.size ?: 0
check(parameterCount > 0, clazz) {
"It's redundant to use @Inject when using " +
"@ContributesRenderer for a Renderer with a zero-arg constructor."
}
}
private fun checkZeroArgConstructor(clazz: KSClassDeclaration) {
val parameterCount = clazz.primaryConstructor?.parameters?.size ?: 0
check(parameterCount == 0, clazz) {
"When using @ContributesRenderer and you need to inject types in the constructor, " +
"then it's necessary to add the @Inject annotation."
}
}
private fun KSType.extendsBaseModel(): Boolean {
val superTypes =
(this.declaration as? KSClassDeclaration)?.getAllSuperTypes() ?: emptySequence()
return superTypes.any { it.declaration.qualifiedName?.asString() == baseModelFqName }
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/inject/processor/ContributesRobotProcessor.kt
================================================
package software.amazon.app.platform.inject.processor
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.getAllSuperTypes
import com.google.devtools.ksp.isAnnotationPresent
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.LambdaTypeName
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.WildcardTypeName
import com.squareup.kotlinpoet.asClassName
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.writeTo
import kotlin.reflect.KClass
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.IntoMap
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.KotlinInjectContextAware
import software.amazon.app.platform.inject.addOriginAnnotation
import software.amazon.app.platform.inject.robot.ContributesRobot
import software.amazon.app.platform.ksp.decapitalize
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
/**
* Generates the necessary code in order to support [ContributesRobot].
*
* If you use `@ContributesRobot(AbcScope::class)`, then this code generator will generate a
* component interface, which gets contributed to this scope.
*
* ```
* package app.platform.inject.software.amazon.test
*
* @ContributesTo(scope = AbcScope::class)
* public interface AbcRobotComponent {
* @Provide
* fun provideAbcRobot(): AbcRobot = AbcRobot()
*
* @Provides
* @IntoMap
* fun provideAbcRobotIntoMap(
* robot: () -> AbcRobot,
* ): Pair, () -> Robot> = AbcRobot::class to robot
* }
* ```
*/
@OptIn(KspExperimental::class)
internal class ContributesRobotProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, KotlinInjectContextAware {
private val robotClassName = ClassName("software.amazon.app.platform.robot", "Robot")
private val robotFqName = robotClassName.canonicalName
override fun process(resolver: Resolver): List {
resolver
.getSymbolsWithAnnotation(ContributesRobot::class)
.filterIsInstance()
.onEach {
checkIsPublic(it)
checkHasInjectAnnotation(it)
checkNotSingleton(it)
checkSuperType(it)
checkAppScope(it)
}
.forEach { generateComponentInterface(it) }
return emptyList()
}
private fun generateComponentInterface(clazz: KSClassDeclaration) {
val packageName = "${APP_PLATFORM_LOOKUP_PACKAGE}.${clazz.packageName.asString()}"
val componentClassName = ClassName(packageName, "${clazz.innerClassNames()}Component")
val fileSpec =
FileSpec.builder(componentClassName)
.addType(
TypeSpec.interfaceBuilder(componentClassName)
.addOriginatingKSFile(clazz.requireContainingFile())
.addOriginAnnotation(clazz)
.addAnnotation(
AnnotationSpec.builder(ContributesTo::class)
.addMember("%T::class", clazz.scope().type.toClassName())
.build()
)
.apply {
if (!clazz.isAnnotationPresent(Inject::class)) {
addFunction(
FunSpec.builder("provide${clazz.innerClassNames()}")
.addAnnotation(Provides::class)
.returns(clazz.toClassName())
.addStatement("return %T()", clazz.toClassName())
.build()
)
}
}
.addFunction(
FunSpec.builder("provide${clazz.innerClassNames()}IntoMap")
.addAnnotation(Provides::class)
.addAnnotation(IntoMap::class)
.addParameter(
name = "robot",
type = LambdaTypeName.get(returnType = clazz.toClassName()),
)
.returns(
Pair::class.asClassName()
.parameterizedBy(
listOf(
KClass::class.asClassName()
.parameterizedBy(WildcardTypeName.producerOf(robotClassName)),
LambdaTypeName.get(returnType = robotClassName),
)
)
)
.addStatement("return %T::class·to·robot", clazz.toClassName())
.build()
)
.addProperty(name = clazz.innerClassNames().decapitalize(), type = clazz.toClassName())
.build()
)
.build()
fileSpec.writeTo(codeGenerator, aggregating = false)
}
private fun checkHasInjectAnnotation(clazz: KSClassDeclaration) {
if (clazz.primaryConstructor?.parameters?.isNotEmpty() == true) {
check(clazz.annotations.any { it.isAnnotation(injectFqName) }, clazz) {
"${clazz.simpleName.asString()} must be annotated with @Inject when " +
"injecting arguments into a robot."
}
}
}
private fun checkNotSingleton(clazz: KSClassDeclaration) {
check(clazz.annotations.none { it.isKotlinInjectScopeAnnotation() }, clazz) {
"It's not allowed allowed for a robot to be a singleton, because the lifetime " +
"of the robot is scoped to the robot() factory function. Remove the @" +
clazz.annotations.first { it.isKotlinInjectScopeAnnotation() }.shortName.asString() +
" annotation."
}
}
private fun checkSuperType(clazz: KSClassDeclaration) {
val extendsRobot =
clazz.getAllSuperTypes().any { it.declaration.requireQualifiedName() == robotFqName }
check(extendsRobot, clazz) {
"In order to use @ContributesRobot, ${clazz.simpleName.asString()} must " +
"implement $robotFqName."
}
}
private fun checkAppScope(clazz: KSClassDeclaration) {
val scope = clazz.scope().type.declaration.requireQualifiedName()
check(scope == AppScope::class.requireQualifiedName(), clazz) {
"Robots can only be contributed to the AppScope for now. Scope $scope is unsupported."
}
}
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/CommonSourceCode.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject
import com.tschuchort.compiletesting.JvmCompilationResult
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.descriptors.runtime.structure.primitiveByWrapper
import software.amazon.app.platform.ksp.capitalize
import software.amazon.lastmile.kotlin.inject.anvil.internal.Origin
internal val JvmCompilationResult.componentInterface: Class<*>
get() = classLoader.loadClass("software.amazon.test.ComponentInterface")
internal val Class<*>.origin: Class<*>
get() = getAnnotation(Origin::class.java).value.java
internal val Class<*>.generatedComponent: Class<*>
get() =
classLoader.loadClass(
"$OPEN_SOURCE_LOOKUP_PACKAGE." +
canonicalName.split(".").joinToString(separator = "") { it.capitalize() }
)
internal fun Class<*>.newComponent(vararg arguments: Any): T {
@Suppress("UNCHECKED_CAST")
return classLoader
.loadClass("$packageName.Inject$simpleName")
.getDeclaredConstructor(
*arguments.map { arg -> arg::class.java.primitiveByWrapper ?: arg::class.java }.toTypedArray()
)
.newInstance(*arguments) as T
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/Compilation.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject
import assertk.assertThat
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import com.tschuchort.compiletesting.JvmCompilationResult
import com.tschuchort.compiletesting.KotlinCompilation
import com.tschuchort.compiletesting.SourceFile
import com.tschuchort.compiletesting.addPreviousResultToClasspath
import com.tschuchort.compiletesting.configureKsp
import java.io.File
import java.io.OutputStream
import java.nio.file.Files
import java.util.ServiceLoader
import org.intellij.lang.annotations.Language
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.config.JvmTarget
import software.amazon.app.platform.ksp.isError
import software.amazon.app.platform.ksp.isOk
/** A simple API over a [KotlinCompilation] with extra configuration support for KSP processors. */
// Inspired by Anvil:
// https://github.com/square/anvil/blob/97e2cc0430311c6b0ed5341da95bb243b582fab8/compiler-utils/src/testFixtures/java/com/squareup/anvil/compiler/internal/testing/AnvilCompilation.kt
class Compilation internal constructor(val kotlinCompilation: KotlinCompilation) {
private var isCompiled = false
private var processorsConfigured = false
/** Configures the behavior of this compilation. */
fun configureAppPlatformProcessor(): Compilation = apply {
checkNotCompiled()
check(!processorsConfigured) { "Processor should not be configured twice." }
processorsConfigured = true
kotlinCompilation.configureKsp() {
symbolProcessorProviders +=
ServiceLoader.load(
SymbolProcessorProvider::class.java,
SymbolProcessorProvider::class.java.classLoader,
)
processorOptions +=
"software.amazon.lastmile.kotlin.inject.anvil.processor." + "ContributesBindingProcessor" to
"disabled"
// Run KSP embedded directly within this kotlinc invocation
withCompilation = true
incremental = true
}
}
/** Adds the given sources to this compilation with their packages and names inferred. */
fun addSources(@Language("kotlin") vararg sources: String): Compilation = apply {
checkNotCompiled()
kotlinCompilation.sources += sources.mapIndexed { index, content ->
val packageDir =
content
.lines()
.firstOrNull { it.trim().startsWith("package ") }
?.substringAfter("package ")
?.replace('.', '/')
?.let { "$it/" } ?: ""
val name =
"${kotlinCompilation.workingDir.absolutePath}/sources/src/main/java/" +
"$packageDir/Source$index.kt"
Files.createDirectories(File(name).parentFile.toPath())
SourceFile.kotlin(name, contents = content, trimIndent = true)
}
}
fun addPreviousCompilationResult(result: JvmCompilationResult): Compilation = apply {
checkNotCompiled()
kotlinCompilation.addPreviousResultToClasspath(result)
}
private fun checkNotCompiled() {
check(!isCompiled) {
"Already compiled! Create a new compilation if you want to compile again."
}
}
/**
* Compiles the underlying [KotlinCompilation]. Note that if [configureAppPlatformProcessor] has
* not been called prior to this, it will be configured with default behavior.
*/
fun compile(
@Language("kotlin") vararg sources: String,
block: JvmCompilationResult.() -> Unit = {},
): JvmCompilationResult {
checkNotCompiled()
if (!processorsConfigured) {
// Configure with default behaviors
configureAppPlatformProcessor()
}
addSources(*sources)
isCompiled = true
return kotlinCompilation.compile().apply(block)
}
companion object {
operator fun invoke(): Compilation {
return Compilation(
KotlinCompilation().apply {
// Sensible default behaviors
inheritClassPath = true
jvmTarget = JvmTarget.JVM_1_8.description
verbose = false
}
)
}
}
}
/**
* Helpful for testing code generators in unit tests end to end.
*
* This covers common cases, but is built upon reusable logic in [Compilation] and
* [Compilation.configureAppPlatformProcessor]. Consider using those APIs if more advanced
* configuration is needed.
*/
fun compile(
@Language("kotlin") vararg sources: String,
allWarningsAsErrors: Boolean = true,
messageOutputStream: OutputStream = System.out,
workingDir: File? = null,
previousCompilationResult: JvmCompilationResult? = null,
moduleName: String? = null,
exitCode: KotlinCompilation.ExitCode = KotlinCompilation.ExitCode.OK,
block: JvmCompilationResult.() -> Unit = {},
): JvmCompilationResult {
return Compilation()
.apply {
kotlinCompilation.apply {
this.allWarningsAsErrors = allWarningsAsErrors
this.messageOutputStream = messageOutputStream
if (workingDir != null) {
this.workingDir = workingDir
}
if (moduleName != null) {
this.moduleName = moduleName
}
}
if (previousCompilationResult != null) {
addPreviousCompilationResult(previousCompilationResult)
}
}
.configureAppPlatformProcessor()
.compile(*sources)
.also {
if (exitCode == KotlinCompilation.ExitCode.OK) {
assertThat(it.exitCode).isOk()
} else {
assertThat(it.exitCode).isError()
}
}
.also(block)
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/CompilerTestUtil.kt
================================================
package software.amazon.app.platform.inject
import java.lang.reflect.Method
// Following changes to Kotlin starting in 2.2.0,
// https://kotlinlang.org/docs/whatsnew22.html#changes-to-default-method-generation-for-interface-functions
// default methods are generated where they previously weren't. For testing we only validate the non
// synthetic methods.
internal val Class<*>.declaredNonSyntheticMethods: List
get() = declaredMethods.filterNot { it.isSynthetic }
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/processor/ContributesBindingProcessorTest.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject.processor
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import com.tschuchort.compiletesting.JvmCompilationResult
import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.junit.jupiter.api.Test
import software.amazon.app.platform.inject.OPEN_SOURCE_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.compile
import software.amazon.app.platform.inject.declaredNonSyntheticMethods
import software.amazon.app.platform.inject.generatedComponent
import software.amazon.app.platform.inject.origin
import software.amazon.app.platform.ksp.inner
import software.amazon.app.platform.ksp.isAnnotatedWith
import software.amazon.app.platform.ksp.isNotAnnotatedWith
class ContributesBindingProcessorTest {
@Test
fun `a component interface is generated in the lookup package for a contributed binding`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
@Inject
@ContributesBinding(Unit::class)
class Impl : Base
"""
) {
val generatedComponent = impl.generatedComponent
assertThat(generatedComponent.packageName).isEqualTo(OPEN_SOURCE_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(impl)
val method = generatedComponent.declaredNonSyntheticMethods.single()
assertThat(method.name).isEqualTo("provideImplBase")
assertThat(method.parameters.single().type).isEqualTo(impl)
assertThat(method.returnType).isEqualTo(base)
assertThat(method).isAnnotatedWith(Provides::class)
}
}
@Test
fun `a component interface is generated in the lookup package for an inner contributed binding`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
interface Impl {
@Inject
@ContributesBinding(Unit::class)
class Inner : Base
}
"""
) {
val generatedComponent = impl.inner.generatedComponent
assertThat(generatedComponent.packageName).isEqualTo(OPEN_SOURCE_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(impl.inner)
val method = generatedComponent.declaredNonSyntheticMethods.single()
assertThat(method.name).isEqualTo("provideImplInnerBase")
assertThat(method.parameters.single().type).isEqualTo(impl.inner)
assertThat(method.returnType).isEqualTo(base)
assertThat(method).isAnnotatedWith(Provides::class)
}
}
@Test
fun `the explicit bound type has a higher priority`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
interface Base2 : Base
@Inject
@ContributesBinding(Unit::class, boundType = Base::class)
class Impl : Base2
@Inject
@ContributesBinding(Unit::class)
class Impl2 : Base2
"""
) {
assertThat(impl.generatedComponent.declaredNonSyntheticMethods.single().returnType)
.isEqualTo(base)
assertThat(impl2.generatedComponent.declaredNonSyntheticMethods.single().returnType)
.isEqualTo(base2)
}
}
@Test
fun `it's an error when there's no super type`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
@Inject
@ContributesBinding(Unit::class)
class Impl
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains("The bound type could not be determined for Impl. There are no super types.")
}
}
@Test
fun `it's an error when there are multiple super types`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
interface Base2
@Inject
@ContributesBinding(Unit::class)
class Impl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains(
"The bound type could not be determined for Impl. " +
"There are multiple super types: Base, Base2."
)
}
}
@Test
fun `bindings are repeatable`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
interface Base2
@Inject
@ContributesBinding(Unit::class, boundType = Base::class)
@ContributesBinding(Unit::class, boundType = Base2::class)
class Impl : Base, Base2
"""
) {
val generatedComponent = impl.generatedComponent
assertThat(generatedComponent.packageName).isEqualTo(OPEN_SOURCE_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(impl)
with(generatedComponent.declaredNonSyntheticMethods.single { it.name == "provideImplBase" }) {
assertThat(parameters.single().type).isEqualTo(impl)
assertThat(returnType).isEqualTo(base)
assertThat(this).isAnnotatedWith(Provides::class)
}
with(
generatedComponent.declaredNonSyntheticMethods.single { it.name == "provideImplBase2" }
) {
assertThat(parameters.single().type).isEqualTo(impl)
assertThat(returnType).isEqualTo(base2)
assertThat(this).isAnnotatedWith(Provides::class)
}
}
}
@Test
fun `it's an error to use different scopes for multiple bindings`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
interface Base2
@Inject
@ContributesBinding(scope = String::class, boundType = Base::class)
@ContributesBinding(scope = Unit::class, boundType = Base2::class)
class Impl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages).contains("All scopes on annotations must be the same.")
}
}
@Test
fun `it's an error to duplicate the same binding`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
@Inject
@ContributesBinding(Unit::class, boundType = Base::class)
@ContributesBinding(Unit::class, boundType = Base::class)
class Impl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains("The same type should not be contributed twice: software.amazon.test.Base.")
}
}
@Test
fun `a component interface is generated in the lookup package for a contributed multibinding`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
@Inject
@ContributesBinding(Unit::class, multibinding = true)
class Impl : Base
"""
) {
val generatedComponent = impl.generatedComponent
assertThat(generatedComponent.packageName).isEqualTo(OPEN_SOURCE_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(impl)
val method = generatedComponent.declaredNonSyntheticMethods.single()
assertThat(method.name).isEqualTo("provideImplBaseMultibinding")
assertThat(method.parameters.single().type).isEqualTo(impl)
assertThat(method.returnType).isEqualTo(base)
assertThat(method).isAnnotatedWith(Provides::class)
assertThat(method).isAnnotatedWith(IntoSet::class)
}
}
@Test
fun `both binding and multibinding component interfaces can be generated in the lookup package for a contributed multibinding`() {
compile(
"""
package software.amazon.test
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import me.tatarka.inject.annotations.Inject
interface Base
@Inject
@ContributesBinding(Unit::class, multibinding = false)
@ContributesBinding(Unit::class, multibinding = true)
class Impl : Base
"""
) {
val generatedComponent = impl.generatedComponent
assertThat(generatedComponent.packageName).isEqualTo(OPEN_SOURCE_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(impl)
assertThat(generatedComponent.declaredNonSyntheticMethods).hasSize(2)
val bindingMethod =
generatedComponent.declaredNonSyntheticMethods.first { it.name == "provideImplBase" }
assertThat(bindingMethod.parameters.single().type).isEqualTo(impl)
assertThat(bindingMethod.returnType).isEqualTo(base)
assertThat(bindingMethod).isAnnotatedWith(Provides::class)
assertThat(bindingMethod).isNotAnnotatedWith(IntoSet::class)
val multibindingBindingMethod =
generatedComponent.declaredNonSyntheticMethods.first {
it.name == "provideImplBaseMultibinding"
}
assertThat(multibindingBindingMethod.parameters.single().type).isEqualTo(impl)
assertThat(multibindingBindingMethod.returnType).isEqualTo(base)
assertThat(multibindingBindingMethod).isAnnotatedWith(Provides::class)
assertThat(multibindingBindingMethod).isAnnotatedWith(IntoSet::class)
}
}
private val JvmCompilationResult.base: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base")
private val JvmCompilationResult.base2: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base2")
private val JvmCompilationResult.impl: Class<*>
get() = classLoader.loadClass("software.amazon.test.Impl")
private val JvmCompilationResult.impl2: Class<*>
get() = classLoader.loadClass("software.amazon.test.Impl2")
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/processor/ContributesBindingScopedProcessorTest.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject.processor
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import com.tschuchort.compiletesting.JvmCompilationResult
import kotlin.test.assertFailsWith
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.junit.jupiter.api.Test
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.compile
import software.amazon.app.platform.inject.componentInterface
import software.amazon.app.platform.inject.declaredNonSyntheticMethods
import software.amazon.app.platform.inject.generatedComponent
import software.amazon.app.platform.inject.newComponent
import software.amazon.app.platform.inject.origin
import software.amazon.app.platform.ksp.capitalize
import software.amazon.app.platform.ksp.inner
import software.amazon.app.platform.ksp.isAnnotatedWith
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
class ContributesBindingScopedProcessorTest {
@Test
fun `a binding method for Scoped is generated`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class Impl : Base, Scoped
"""
) {
val generatedComponent = impl.scopedComponent
assertThat(generatedComponent.origin).isEqualTo(impl)
assertThat(generatedComponent.getAnnotation(ContributesTo::class.java).scope)
.isEqualTo(AppScope::class)
with(
generatedComponent.declaredNonSyntheticMethods.single { it.name == "provideImplScoped" }
) {
assertThat(parameters.single().type).isEqualTo(impl)
assertThat(returnType).isEqualTo(scoped)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoSet::class)
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(AppScope::class)
}
}
}
@Test
fun `a binding method for Scoped is generated for inner classes`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
interface Impl {
@Inject
@ContributesBinding(Unit::class)
class Inner : Base, Scoped
}
"""
) {
val generatedComponent = impl.inner.scopedComponent
assertThat(generatedComponent.origin).isEqualTo(impl.inner)
assertThat(generatedComponent.getAnnotation(ContributesTo::class.java).scope)
.isEqualTo(Unit::class)
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideImplInnerScoped"
}
) {
assertThat(parameters.single().type).isEqualTo(impl.inner)
assertThat(returnType).isEqualTo(scoped)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoSet::class)
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(Unit::class)
}
}
}
@Test
fun `a binding method for Scoped is generated for repeated annotations`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
interface Base2
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, boundType = Base::class)
@ContributesBinding(AppScope::class, boundType = Base2::class)
class Impl : Base, Base2, Scoped
"""
) {
val generatedComponent = impl.scopedComponent
with(
generatedComponent.declaredNonSyntheticMethods.single { it.name == "provideImplScoped" }
) {
assertThat(parameters.single().type).isEqualTo(impl)
assertThat(returnType).isEqualTo(scoped)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoSet::class)
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(AppScope::class)
}
}
}
@Test
fun `a binding method for Scoped is generated without any other binding`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class Impl : Scoped
"""
) {
val generatedComponent = impl.scopedComponent
with(generatedComponent.declaredNonSyntheticMethods.single()) {
assertThat(name).isEqualTo("provideImplScoped")
assertThat(parameters.single().type).isEqualTo(impl)
assertThat(returnType).isEqualTo(scoped)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoSet::class)
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(AppScope::class)
}
// Because Scoped is the only super type.
assertFailsWith { impl.generatedComponent }
}
}
@Test
fun `a binding method for Scoped is generated only explicitly when Scoped is part of the supertype hierarchy`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base : Scoped
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class Impl : Base
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, boundType = Base::class)
@ContributesBinding(AppScope::class, boundType = Scoped::class)
class Impl2 : Base
"""
) {
with(impl.generatedComponent.declaredNonSyntheticMethods.single()) {
assertThat(name).isEqualTo("provideImplBase")
assertThat(parameters.single().type).isEqualTo(impl)
assertThat(returnType).isEqualTo(base)
assertThat(this).isAnnotatedWith(Provides::class)
}
// Because Scoped is not a direct super type.
assertFailsWith { impl.scopedComponent }
with(impl2.generatedComponent.declaredNonSyntheticMethods.single()) {
assertThat(parameters.single().type).isEqualTo(impl2)
assertThat(returnType).isEqualTo(base)
assertThat(this).isAnnotatedWith(Provides::class)
}
with(
impl2.scopedComponent.declaredNonSyntheticMethods.single { it.name == "provideImpl2Scoped" }
) {
assertThat(parameters.single().type).isEqualTo(impl2)
assertThat(returnType).isEqualTo(scoped)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoSet::class)
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(AppScope::class)
}
}
}
@Test
fun `scoped instances are added to the component`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.renderer.RendererComponent
import software.amazon.app.platform.robot.RobotComponent
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Component
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class Impl : Base, Scoped
@Inject
@SingleIn(Unit::class)
@ContributesBinding(Unit::class)
class Impl2 : Base, Scoped
@Component
@MergeComponent(AppScope::class, exclude = [RendererComponent::class, RobotComponent::class])
@SingleIn(AppScope::class)
interface ComponentInterface : ComponentInterfaceMerged {
@ForScope(AppScope::class)
val scoped: Set
}
@Component
@MergeComponent(Unit::class)
@SingleIn(Unit::class)
interface ComponentInterface2 : ComponentInterface2Merged {
@ForScope(Unit::class)
val scoped: Set
}
"""
) {
val component = componentInterface.newComponent()
@Suppress("UNCHECKED_CAST")
val scoped =
component::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getScoped" }
.invoke(component) as Set
assertThat(scoped).hasSize(1)
assertThat(scoped.single()::class.java).isEqualTo(impl)
val component2 = componentInterface2.newComponent()
@Suppress("UNCHECKED_CAST")
val scoped2 =
component2::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getScoped" }
.invoke(component2) as Set
assertThat(scoped2).hasSize(1)
assertThat(scoped2.single()::class.java).isEqualTo(impl2)
}
}
private val Class<*>.scopedComponent: Class<*>
get() =
classLoader.loadClass(
"$APP_PLATFORM_LOOKUP_PACKAGE.$packageName." +
canonicalName.substringAfter("$packageName.").split(".").joinToString(separator = "") {
it.capitalize()
} +
"ScopedComponent"
)
private val JvmCompilationResult.componentInterface2: Class<*>
get() = classLoader.loadClass("software.amazon.test.ComponentInterface2")
private val JvmCompilationResult.base: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base")
private val JvmCompilationResult.impl: Class<*>
get() = classLoader.loadClass("software.amazon.test.Impl")
private val JvmCompilationResult.impl2: Class<*>
get() = classLoader.loadClass("software.amazon.test.Impl2")
private val scoped: Class<*>
get() = Scoped::class.java
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/processor/ContributesMockImplGeneratorTest.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject.processor
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import com.tschuchort.compiletesting.JvmCompilationResult
import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR
import java.lang.reflect.WildcardType
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.descriptors.runtime.structure.parameterizedTypeArguments
import org.junit.jupiter.api.Test
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.compile
import software.amazon.app.platform.inject.componentInterface
import software.amazon.app.platform.inject.declaredNonSyntheticMethods
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.inject.mock.RealImpl
import software.amazon.app.platform.inject.newComponent
import software.amazon.app.platform.ksp.capitalize
import software.amazon.app.platform.ksp.inner
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
class ContributesMockImplGeneratorTest {
@Test
fun `correct provides method is generated`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesMockImpl(AppScope::class)
class MockImpl : Base
"""
) {
val component = mockImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(Boolean::class.java)
assertThat(providesMethod.parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(mockImpl)
assertThat(
providesMethod.parameters[2]
.parameterizedType
.parameterizedTypeArguments
.filterIsInstance()
.single()
.upperBounds
.single()
)
.isEqualTo(base)
assertThat(providesMethod.parameters[2].annotations.single().annotationClass)
.isEqualTo(RealImpl::class)
assertThat(providesMethod.returnType).isEqualTo(base)
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
}
}
@Test
fun `correct provides method is generated with boundType`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesMockImpl(AppScope::class, boundType = Base::class)
class MockImpl : Base
"""
) {
val component = mockImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(Boolean::class.java)
assertThat(providesMethod.parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(mockImpl)
assertThat(
providesMethod.parameters[2]
.parameterizedType
.parameterizedTypeArguments
.filterIsInstance()
.single()
.upperBounds
.single()
)
.isEqualTo(base)
assertThat(providesMethod.parameters[2].annotations.single().annotationClass)
.isEqualTo(RealImpl::class)
assertThat(providesMethod.returnType).isEqualTo(base)
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
}
}
@Test
fun `correct provides method for inner class is generated`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
class MockImpl {
@ContributesMockImpl(AppScope::class)
class Inner : Base
}
"""
) {
val component = mockImpl.inner.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(Boolean::class.java)
assertThat(providesMethod.parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(mockImpl.inner)
assertThat(
providesMethod.parameters[2]
.parameterizedType
.parameterizedTypeArguments
.filterIsInstance()
.single()
.upperBounds
.single()
)
.isEqualTo(base)
assertThat(providesMethod.parameters[2].annotations.single().annotationClass)
.isEqualTo(RealImpl::class)
assertThat(providesMethod.returnType).isEqualTo(base)
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
}
}
@Test
fun `an abstract class as bound type is supported`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
open class Base
@ContributesMockImpl(AppScope::class)
class MockImpl : Base()
"""
) {
assertThat(mockImpl.component).isNotNull()
}
}
@Test
fun `repeated annotations produce correct component`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
interface Base2
@ContributesMockImpl(AppScope::class, boundType = Base::class)
@ContributesMockImpl(AppScope::class, boundType = Base2::class)
class MockImpl : Base, Base2
"""
) {
val component = mockImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
assertThat(component.declaredNonSyntheticMethods.map { it.name }).contains("provideBase")
assertThat(component.declaredNonSyntheticMethods.map { it.name }).contains("provideBase2")
}
}
@Test
fun `repeated annotations of the same class type throws error`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesMockImpl(AppScope::class, boundType = Base::class)
@ContributesMockImpl(AppScope::class, boundType = Base::class)
class MockImpl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains("The same type should not be contributed twice: software.amazon.test.Base.")
}
}
@Test
fun `repeated annotations of different scopes throws error`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
interface Base2
@ContributesMockImpl(AppScope::class, boundType = Base::class)
@ContributesMockImpl(Unit::class, boundType = Base2::class)
class MockImpl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages).contains("All scopes on annotations must be the same.")
}
}
@Test
fun `when no superType is defined, then an error is thrown`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
@ContributesMockImpl(AppScope::class)
class MockImpl
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains(
"The bound type could not be determined for MockImpl. " + "There are no super types."
)
}
}
@Test
fun `the bound type can be different than the super type`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base : Base2
interface Base2
@ContributesMockImpl(AppScope::class, boundType = Base2::class)
class MockImpl : Base
"""
) {
val component = mockImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(Boolean::class.java)
assertThat(providesMethod.parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(mockImpl)
assertThat(
providesMethod.parameters[2]
.parameterizedType
.parameterizedTypeArguments
.filterIsInstance()
.single()
.upperBounds
.single()
)
.isEqualTo(base2)
assertThat(providesMethod.parameters[2].annotations.single().annotationClass)
.isEqualTo(RealImpl::class)
assertThat(providesMethod.returnType).isEqualTo(base2)
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
}
}
@Test
fun `the bound type must be declared for multiple super types`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
interface Base2
@ContributesMockImpl(AppScope::class)
class MockImpl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains(
"The bound type could not be determined for MockImpl. " +
"There are multiple super types: Base, Base2."
)
}
}
@Test
fun `a provides method for the Scoped type is generated`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesMockImpl(AppScope::class)
class MockImpl : Base, Scoped
"""
) {
val component = mockImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
with(component.declaredNonSyntheticMethods.single { it.name == "provideBase" }) {
assertThat(parameters[0].type).isEqualTo(Boolean::class.java)
assertThat(parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(mockImpl)
assertThat(
parameters[2]
.parameterizedType
.parameterizedTypeArguments
.filterIsInstance()
.single()
.upperBounds
.single()
)
.isEqualTo(base)
assertThat(parameters[2].annotations.single().annotationClass).isEqualTo(RealImpl::class)
assertThat(returnType).isEqualTo(base)
assertThat(getAnnotation(Provides::class.java)).isNotNull()
}
with(component.declaredNonSyntheticMethods.single { it.name == "provideMockImplScoped" }) {
assertThat(parameters[0].annotations.single().annotationClass).isEqualTo(MockMode::class)
assertThat(parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(mockImpl)
assertThat(getAnnotation(Provides::class.java)).isNotNull()
assertThat(getAnnotation(IntoSet::class.java)).isNotNull()
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(AppScope::class)
}
}
}
@Test
fun `a provides method for the Scoped type is skipped when the class is annotated with @ContributesBinding`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
interface Base
interface Base2
@ContributesMockImpl(AppScope::class, boundType = Base::class)
@ContributesBinding(AppScope::class, boundType = Base2::class)
class MockImpl : Base, Base2, Scoped
"""
) {
val component = mockImpl.component
assertThat(component.declaredNonSyntheticMethods.firstOrNull { it.name == "provideBase" })
.isNotNull()
assertThat(
component.declaredNonSyntheticMethods.firstOrNull { it.name == "provideMockImplScoped" }
)
.isNull()
}
}
@Test
fun `another super type besides Scoped is required`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
@ContributesMockImpl(AppScope::class)
class MockImpl : Scoped
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages).contains("Scoped cannot be used as bound type.")
}
}
@Test
fun `the mock or real impl are provided based on the mock mode flag`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.renderer.RendererComponent
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.robot.RobotComponent
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
@Inject
@SingleIn(AppScope::class)
@ContributesRealImpl(AppScope::class)
class RealBaseImpl : Base
@Inject
@SingleIn(AppScope::class)
@ContributesMockImpl(AppScope::class)
class MockImpl : Base
@Component
@MergeComponent(AppScope::class, exclude = [RendererComponent::class, RobotComponent::class])
@SingleIn(AppScope::class)
abstract class ComponentInterface(
@get:Provides @get:MockMode val mockMode: Boolean,
) : ComponentInterfaceMerged {
abstract val base: Base
}
"""
) {
val componentMockModeTrue = componentInterface.newComponent(true)
val componentMockModeFalse = componentInterface.newComponent(false)
assertThat(
componentMockModeTrue::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getBase" }
.invoke(componentMockModeTrue)::class
.java
)
.isEqualTo(mockImpl)
assertThat(
componentMockModeFalse::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getBase" }
.invoke(componentMockModeFalse)::class
.java
)
.isEqualTo(realBaseImpl)
}
}
@Test
fun `the mock or real impl are provided in the Scoped set based on the mock mode flag`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.renderer.RendererComponent
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.robot.RobotComponent
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
@Inject
@SingleIn(AppScope::class)
@ContributesRealImpl(AppScope::class)
class RealBaseImpl : Base, Scoped
@Inject
@SingleIn(AppScope::class)
@ContributesMockImpl(AppScope::class)
class MockImpl : Base, Scoped
@Component
@MergeComponent(AppScope::class, exclude = [RendererComponent::class, RobotComponent::class])
@SingleIn(AppScope::class)
abstract class ComponentInterface(
@get:Provides @get:MockMode val mockMode: Boolean,
) : ComponentInterfaceMerged {
abstract val base: Base
@ForScope(AppScope::class)
abstract val scoped: Set
}
"""
) {
val componentMockModeTrue = componentInterface.newComponent(true)
val componentMockModeFalse = componentInterface.newComponent(false)
@Suppress("UNCHECKED_CAST")
with(
componentMockModeTrue::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getScoped" }
.invoke(componentMockModeTrue) as Set
) {
assertThat(this).hasSize(2)
assertThat(singleOrNull { mockImpl.isAssignableFrom(it.javaClass) }).isNotNull()
assertThat(this).contains(Scoped.NO_OP)
}
@Suppress("UNCHECKED_CAST")
with(
componentMockModeFalse::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getScoped" }
.invoke(componentMockModeFalse) as Set
) {
assertThat(this).hasSize(2)
assertThat(singleOrNull { realBaseImpl.isAssignableFrom(it.javaClass) }).isNotNull()
assertThat(this).contains(Scoped.NO_OP)
}
}
}
@Test
fun `a contributed real impl and mock impl can be excluded`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.renderer.RendererComponent
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.robot.RobotComponent
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
@Inject
@SingleIn(AppScope::class)
@ContributesRealImpl(AppScope::class)
class RealBaseImpl : Base
@Inject
@SingleIn(AppScope::class)
@ContributesMockImpl(AppScope::class)
class MockImpl : Base
@Component
@MergeComponent(AppScope::class, exclude = [RendererComponent::class, RobotComponent::class, RealBaseImpl::class, MockImpl::class])
@SingleIn(AppScope::class)
abstract class ComponentInterface(
@get:Provides @get:MockMode val mockMode: Boolean,
) : ComponentInterfaceMerged {
abstract val base: Base
}
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains("Cannot find an @Inject constructor or provider for: software.amazon.test.Base")
}
// Test again and verify through the Scoped interface
compile(
"""
package software.amazon.test
import software.amazon.app.platform.renderer.RendererComponent
import software.amazon.app.platform.inject.mock.ContributesMockImpl
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.robot.RobotComponent
import software.amazon.app.platform.scope.Scoped
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
interface Base
@Inject
@SingleIn(AppScope::class)
@ContributesRealImpl(AppScope::class)
class RealBaseImpl : Base, Scoped
@Inject
@SingleIn(AppScope::class)
@ContributesMockImpl(AppScope::class)
class MockImpl : Base, Scoped
@Component
@MergeComponent(AppScope::class, exclude = [RendererComponent::class, RobotComponent::class, RealBaseImpl::class, MockImpl::class])
@SingleIn(AppScope::class)
abstract class ComponentInterface : ComponentInterfaceMerged {
@ForScope(AppScope::class)
abstract val scoped: Set
@Provides
@IntoSet
@ForScope(AppScope::class)
fun provideTestScoped(): Scoped = TestScoped
}
object TestScoped : Scoped
"""
) {
val component = componentInterface.newComponent()
@Suppress("UNCHECKED_CAST")
val scoped =
component::class
.java
.declaredNonSyntheticMethods
.single { it.name == "getScoped" }
.invoke(component) as Set
assertThat(scoped.single().javaClass.canonicalName)
.isEqualTo("software.amazon.test.TestScoped")
}
}
private val JvmCompilationResult.base: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base")
private val JvmCompilationResult.base2: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base2")
private val JvmCompilationResult.mockImpl: Class<*>
get() = classLoader.loadClass("software.amazon.test.MockImpl")
private val JvmCompilationResult.realBaseImpl: Class<*>
get() = classLoader.loadClass("software.amazon.test.RealBaseImpl")
private val Class<*>.component: Class<*>
get() =
classLoader.loadClass(
"$APP_PLATFORM_LOOKUP_PACKAGE.$packageName." +
canonicalName.substringAfter(packageName).substring(1).split(".").joinToString(
separator = ""
) {
it.capitalize()
} +
"MockImplComponent"
)
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/processor/ContributesRealImplGeneratorTest.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject.processor
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import com.tschuchort.compiletesting.JvmCompilationResult
import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR
import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.descriptors.runtime.structure.parameterizedTypeArguments
import org.junit.jupiter.api.Test
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.compile
import software.amazon.app.platform.inject.declaredNonSyntheticMethods
import software.amazon.app.platform.inject.mock.MockMode
import software.amazon.app.platform.inject.mock.RealImpl
import software.amazon.app.platform.ksp.capitalize
import software.amazon.app.platform.ksp.inner
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
class ContributesRealImplGeneratorTest {
@Test
fun `correct provides method is generated when boundType is inferred`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesRealImpl(AppScope::class)
class RealImpl : Base
"""
) {
val component = realImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(realImpl)
assertThat(providesMethod.returnType).isEqualTo(base)
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
assertThat(providesMethod.getAnnotation(RealImpl::class.java)).isNotNull()
}
}
@Test
fun `correct provides method for inner class is generated`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
class RealImpl {
@ContributesRealImpl(AppScope::class)
class Inner : Base
}
"""
) {
val component = realImpl.inner.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(realImpl.inner)
assertThat(providesMethod.returnType).isEqualTo(base)
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
assertThat(providesMethod.getAnnotation(RealImpl::class.java)).isNotNull()
}
}
@Test
fun `repeated annotations produce correct component`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
interface Base2
@ContributesRealImpl(AppScope::class, boundType = Base::class)
@ContributesRealImpl(AppScope::class, boundType = Base2::class)
class RealImpl : Base, Base2
"""
) {
val component = realImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod1 =
component.declaredNonSyntheticMethods.single { it.name == "provideBaseRealImpl" }
val providesMethod2 =
component.declaredNonSyntheticMethods.single { it.name == "provideBase2RealImpl" }
assertThat(providesMethod1.parameters[0].type).isEqualTo(realImpl)
assertThat(providesMethod1.returnType).isEqualTo(base)
assertThat(providesMethod1.getAnnotation(Provides::class.java)).isNotNull()
assertThat(providesMethod1.getAnnotation(RealImpl::class.java)).isNotNull()
assertThat(providesMethod2.parameters[0].type).isEqualTo(realImpl)
assertThat(providesMethod2.returnType).isEqualTo(base2)
assertThat(providesMethod2.getAnnotation(Provides::class.java)).isNotNull()
assertThat(providesMethod2.getAnnotation(RealImpl::class.java)).isNotNull()
}
}
@Test
fun `repeated annotations of the same class type throws error`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesRealImpl(AppScope::class, boundType = Base::class)
@ContributesRealImpl(AppScope::class, boundType = Base::class)
class RealImpl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains("The same type should not be contributed twice: software.amazon.test.Base.")
}
}
@Test
fun `repeated annotations of different scopes throws error`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
interface Base2
@ContributesRealImpl(AppScope::class, boundType = Base::class)
@ContributesRealImpl(Unit::class, boundType = Base2::class)
class RealImpl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages).contains("All scopes on annotations must be the same.")
}
}
@Test
fun `when no superType is defined, then an error is thrown`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
@ContributesRealImpl(AppScope::class)
class RealImpl
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains(
"The bound type could not be determined for RealImpl. " + "There are no super types."
)
}
}
@Test
fun `an abstract class as bound type is supported`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
open class Base
@ContributesRealImpl(AppScope::class)
class RealImpl : Base()
"""
) {
assertThat(realImpl.component).isNotNull()
}
}
@Test
fun `the bound type can be different than the super type`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base : Base2
interface Base2
@ContributesRealImpl(AppScope::class, boundType = Base2::class)
class RealImpl : Base
"""
) {
val component = realImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
val providesMethod = component.declaredNonSyntheticMethods.single()
assertThat(providesMethod.parameters[0].type).isEqualTo(realImpl)
assertThat(providesMethod.returnType).isEqualTo(base2)
assertThat(providesMethod.name).isEqualTo("provideBase2RealImpl")
assertThat(providesMethod.getAnnotation(Provides::class.java)).isNotNull()
assertThat(providesMethod.getAnnotation(RealImpl::class.java)).isNotNull()
}
}
@Test
fun `the bound type must be declared for multiple super types`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
interface Base2
@ContributesRealImpl(AppScope::class)
class RealImpl : Base, Base2
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages)
.contains(
"The bound type could not be determined for RealImpl. " +
"There are multiple super types: Base, Base2."
)
}
}
@Test
fun `a provides method for the Scoped type is generated`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
interface Base
@ContributesRealImpl(AppScope::class)
class RealImpl : Base, Scoped
"""
) {
val component = realImpl.component
assertThat(component.getAnnotation(ContributesTo::class.java)?.scope)
.isEqualTo(AppScope::class)
with(component.declaredNonSyntheticMethods.single { it.name == "provideBaseRealImpl" }) {
assertThat(parameters[0].type).isEqualTo(realImpl)
assertThat(returnType).isEqualTo(base)
assertThat(getAnnotation(Provides::class.java)).isNotNull()
assertThat(getAnnotation(RealImpl::class.java)).isNotNull()
}
with(component.declaredNonSyntheticMethods.single { it.name == "provideRealImplScoped" }) {
assertThat(parameters[0].annotations.single().annotationClass).isEqualTo(MockMode::class)
assertThat(parameters[1].parameterizedType.parameterizedTypeArguments.single())
.isEqualTo(realImpl)
assertThat(getAnnotation(Provides::class.java)).isNotNull()
assertThat(getAnnotation(IntoSet::class.java)).isNotNull()
assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(AppScope::class)
}
}
}
@Test
fun `a provides method for the Scoped type is skipped when the class is annotated with @ContributesBinding`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
interface Base
interface Base2
@ContributesRealImpl(AppScope::class, boundType = Base::class)
@ContributesBinding(AppScope::class, boundType = Base2::class)
class RealImpl : Base, Base2, Scoped
"""
) {
val component = realImpl.component
with(component.declaredNonSyntheticMethods.single { it.name == "provideBaseRealImpl" }) {
assertThat(parameters[0].type).isEqualTo(realImpl)
assertThat(returnType).isEqualTo(base)
assertThat(getAnnotation(Provides::class.java)).isNotNull()
assertThat(getAnnotation(RealImpl::class.java)).isNotNull()
}
assertThat(
component.declaredNonSyntheticMethods.firstOrNull { it.name == "provideRealImplScoped" }
)
.isNull()
}
}
@Test
fun `another super type besides Scoped is required`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.mock.ContributesRealImpl
import software.amazon.app.platform.scope.Scoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
@ContributesRealImpl(AppScope::class)
class RealImpl : Scoped
""",
exitCode = COMPILATION_ERROR,
) {
assertThat(messages).contains("Scoped cannot be used as bound type.")
}
}
private val JvmCompilationResult.base: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base")
private val JvmCompilationResult.base2: Class<*>
get() = classLoader.loadClass("software.amazon.test.Base2")
private val JvmCompilationResult.realImpl: Class<*>
get() = classLoader.loadClass("software.amazon.test.RealImpl")
private val Class<*>.component: Class<*>
get() =
classLoader.loadClass(
"$APP_PLATFORM_LOOKUP_PACKAGE.$packageName." +
canonicalName.substringAfter(packageName).substring(1).split(".").joinToString(
separator = ""
) {
it.capitalize()
} +
"RealImplComponent"
)
}
================================================
FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/processor/ContributesRendererProcessorTest.kt
================================================
@file:OptIn(ExperimentalCompilerApi::class)
package software.amazon.app.platform.inject.processor
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.containsOnly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import assertk.assertions.startsWith
import com.tschuchort.compiletesting.JvmCompilationResult
import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR
import java.lang.reflect.Proxy
import kotlin.reflect.KClass
import me.tatarka.inject.annotations.IntoMap
import me.tatarka.inject.annotations.Provides
import org.intellij.lang.annotations.Language
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.junit.jupiter.api.Test
import software.amazon.app.platform.inject.APP_PLATFORM_LOOKUP_PACKAGE
import software.amazon.app.platform.inject.compile
import software.amazon.app.platform.inject.componentInterface
import software.amazon.app.platform.inject.declaredNonSyntheticMethods
import software.amazon.app.platform.inject.newComponent
import software.amazon.app.platform.inject.origin
import software.amazon.app.platform.ksp.inner
import software.amazon.app.platform.ksp.isAnnotatedWith
import software.amazon.app.platform.renderer.RendererComponent
import software.amazon.app.platform.renderer.RendererScope
import software.amazon.lastmile.kotlin.inject.anvil.ForScope
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
class ContributesRendererProcessorTest {
@Test
fun `a component interface is generated in the lookup package for a contributed renderer`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.renderer.Renderer
import software.amazon.app.platform.inject.ContributesRenderer
class Model : BaseModel
@ContributesRenderer
class TestRenderer : Renderer {
override fun render(model: Model) = Unit
}
""",
componentInterfaceSource,
) {
val generatedComponent = testRenderer.rendererComponent
assertThat(generatedComponent.packageName).startsWith(APP_PLATFORM_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(testRenderer)
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRenderer"
}
) {
assertThat(parameters).isEmpty()
assertThat(returnType).isEqualTo(testRenderer)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(getAnnotation(SingleIn::class.java)).isNull()
}
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRendererModel"
}
) {
assertThat(parameters.single().type.canonicalName)
.isEqualTo("kotlin.jvm.functions.Function0")
assertThat(returnType).isEqualTo(Pair::class.java)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoMap::class)
}
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRendererModelKey"
}
) {
assertThat(parameters).isEmpty()
assertThat(returnType).isEqualTo(Pair::class.java)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoMap::class)
assertThat(this.getAnnotation(ForScope::class.java).scope).isEqualTo(RendererScope::class)
}
assertThat(componentInterface.newComponent().renderers.keys)
.containsOnly(model)
assertThat(componentInterface.newComponent().modelToRendererMapping.keys)
.containsOnly(model)
assertThat(componentInterface.newComponent().modelToRendererMapping.values)
.containsOnly(testRenderer.kotlin)
}
}
@Test
fun `a component interface is generated in the lookup package for a contributed renderer as inner class`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.renderer.Renderer
import software.amazon.app.platform.inject.ContributesRenderer
class Model : BaseModel
class TestRenderer {
@ContributesRenderer
class Inner : Renderer {
override fun render(model: Model) = Unit
}
}
""",
componentInterfaceSource,
) {
val generatedComponent = testRenderer.inner.rendererComponent
assertThat(generatedComponent.packageName).startsWith(APP_PLATFORM_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(testRenderer.inner)
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRendererInner"
}
) {
assertThat(parameters).isEmpty()
assertThat(returnType).isEqualTo(testRenderer.inner)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(getAnnotation(SingleIn::class.java)).isNull()
}
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRendererInnerModel"
}
) {
assertThat(parameters.single().type.canonicalName)
.isEqualTo("kotlin.jvm.functions.Function0")
assertThat(returnType).isEqualTo(Pair::class.java)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoMap::class)
}
assertThat(componentInterface.newComponent().renderers.keys)
.containsOnly(model)
}
}
@Test
fun `a component interface is generated in the lookup package for a contributed renderer with a model as inner class`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.renderer.Renderer
import software.amazon.app.platform.inject.ContributesRenderer
class Presenter {
class Model : BaseModel
}
@ContributesRenderer
class TestRenderer : Renderer {
override fun render(model: Presenter.Model) = Unit
}
""",
componentInterfaceSource,
) {
val generatedComponent = testRenderer.rendererComponent
assertThat(generatedComponent.packageName).startsWith(APP_PLATFORM_LOOKUP_PACKAGE)
assertThat(generatedComponent.origin).isEqualTo(testRenderer)
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRenderer"
}
) {
assertThat(parameters).isEmpty()
assertThat(returnType).isEqualTo(testRenderer)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(getAnnotation(SingleIn::class.java)).isNull()
}
with(
generatedComponent.declaredNonSyntheticMethods.single {
it.name == "provideSoftwareAmazonTestTestRendererPresenterModel"
}
) {
assertThat(parameters.single().type.canonicalName)
.isEqualTo("kotlin.jvm.functions.Function0")
assertThat(returnType).isEqualTo(Pair::class.java)
assertThat(this).isAnnotatedWith(Provides::class)
assertThat(this).isAnnotatedWith(IntoMap::class)
}
assertThat(componentInterface.newComponent().renderers.keys)
.containsOnly(presenter.model.kotlin)
}
}
@Test
fun `the explicit model type has a higher priority`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.renderer.Renderer
class Model : BaseModel
class Model2 : BaseModel
@ContributesRenderer(Model::class)
class TestRenderer : Renderer {
override fun render(model: Model2) = Unit
}
""",
componentInterfaceSource,
) {
assertThat(componentInterface.newComponent().renderers.keys)
.containsOnly(model)
}
}
@Test
fun `the model type can be inferred from the class hierarchy`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.renderer.Renderer
class Model : BaseModel
interface OtherRenderer : Renderer
@ContributesRenderer
class TestRenderer : OtherRenderer {
override fun render(model: Model) = Unit
}
"""
) {
assertThat(testRenderer.modelType).isEqualTo(model)
}
}
@Test
fun `the model type can be inferred from the class hierarchy with multiple levels`() {
compile(
"""
package software.amazon.test
import software.amazon.app.platform.inject.ContributesRenderer
import software.amazon.app.platform.presenter.BaseModel
import software.amazon.app.platform.renderer.Renderer
class Model : BaseModel
interface OtherRenderer : Renderer
interface OtherRenderer2