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 [![Maven Central](https://img.shields.io/maven-central/v/software.amazon.app.platform/gradle-plugin.svg?label=Maven%20Central)](https://central.sonatype.com/search?smo=true&namespace=software.amazon.app.platform) [![CI](https://github.com/amzn/app-platform/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/amzn/app-platform/actions/workflows/ci.yml) App Platform 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 ![App Platform](images/app-platform-logo.png){ 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" ![Android screenshot](images/Android.png){ width="300" } === "iOS" ![iOS screenshot](images/iOS.png){ width="300" } === "Desktop" ![Desktop screenshot](images/Desktop.png){ 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. ![Forbidden dependency](images/module-structure-forbidden-dep.png){ 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: ![Module types](images/module-structure-types.png){ 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: ![Module structure example](images/module-structure-example.png) 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: ![Unidirectional dataflow](images/unidirectional-dataflow.png){ 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. ![Testing pyramid](images/testing-pyramid.png){ 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 : OtherRenderer interface OtherRenderer3 : OtherRenderer2 interface OtherRenderer4 : OtherRenderer3 @ContributesRenderer class TestRenderer : OtherRenderer4 { override fun render(model: Model) = Unit } """ ) { assertThat(testRenderer.modelType).isEqualTo(model) } } @Test fun `the model type must be explicit when it cannot be inferred`() { 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 Model1 : BaseModel class Model2 : BaseModel interface OtherRenderer : Renderer @ContributesRenderer class TestRenderer : OtherRenderer { override fun render(model: Model) = Unit } """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Couldn't find BaseModel type for TestRenderer. Consider adding " + "an explicit parameter.Found: software.amazon.test.Model1, software.amazon.test.Model2" ) } } @Test fun `the component interface contains multiple binding methods for model hierarchies`() { 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 interface Presenter { sealed interface Model : BaseModel { sealed interface Inner : Model { data object Model1 : Inner data object Model2 : Inner } data object Model2 : Model // Note that this class doesn't extend Model. class OtherSubclass } } @ContributesRenderer class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } """, componentInterfaceSource, ) { val generatedComponent = testRenderer.rendererComponent 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() } val bindingMethods = generatedComponent.declaredNonSyntheticMethods.filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModel") && !it.name.endsWith("Key") } assertThat(bindingMethods.map { it.name }) .containsExactlyInAnyOrder( "provideSoftwareAmazonTestTestRendererPresenterModel", "provideSoftwareAmazonTestTestRendererPresenterModelInner", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel1", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel2", "provideSoftwareAmazonTestTestRendererPresenterModelModel2", ) bindingMethods.forEach { assertThat(it.parameters.single().type.canonicalName) .isEqualTo("kotlin.jvm.functions.Function0") assertThat(it.returnType).isEqualTo(Pair::class.java) assertThat(it).isAnnotatedWith(Provides::class) assertThat(it).isAnnotatedWith(IntoMap::class) } assertThat(componentInterface.newComponent().renderers.keys) .containsExactlyInAnyOrder( presenter.model.kotlin, presenter.model.inner.kotlin, presenter.model.inner.model1.kotlin, presenter.model.inner.model2.kotlin, presenter.model.model2.kotlin, ) val keyBindingMethods = generatedComponent.declaredNonSyntheticMethods.filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModel") && it.name.endsWith("Key") } assertThat(keyBindingMethods.map { it.name }) .containsExactlyInAnyOrder( "provideSoftwareAmazonTestTestRendererPresenterModelKey", "provideSoftwareAmazonTestTestRendererPresenterModelInnerKey", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel1Key", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel2Key", "provideSoftwareAmazonTestTestRendererPresenterModelModel2Key", ) keyBindingMethods.forEach { assertThat(it.parameters).isEmpty() assertThat(it.returnType).isEqualTo(Pair::class.java) assertThat(it).isAnnotatedWith(Provides::class) assertThat(it).isAnnotatedWith(IntoMap::class) assertThat(it).isAnnotatedWith(ForScope::class) } assertThat(componentInterface.newComponent().modelToRendererMapping.keys) .containsExactlyInAnyOrder( presenter.model.kotlin, presenter.model.inner.kotlin, presenter.model.inner.model1.kotlin, presenter.model.inner.model2.kotlin, presenter.model.model2.kotlin, ) assertThat( componentInterface .newComponent() .modelToRendererMapping .values .distinct() ) .containsOnly(testRenderer.kotlin) } } @Test fun `the binding methods for subtypes are not generated when disabled`() { 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 interface Presenter { sealed interface Model : BaseModel { data object Model1 : Model data object Model2 : Model } } @ContributesRenderer(includeSealedSubtypes = false) class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } """, componentInterfaceSource, ) { val generatedComponent = testRenderer.rendererComponent assertThat( generatedComponent.declaredNonSyntheticMethods .filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModel") && !it.name.endsWith("Key") } .map { it.name } ) .containsOnly("provideSoftwareAmazonTestTestRendererPresenterModel") assertThat( generatedComponent.declaredNonSyntheticMethods .filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModelKey") } .map { it.name } ) .containsOnly("provideSoftwareAmazonTestTestRendererPresenterModelKey") assertThat(componentInterface.newComponent().renderers.keys) .containsOnly(presenter.model.kotlin) assertThat(componentInterface.newComponent().modelToRendererMapping.keys) .containsOnly(presenter.model.kotlin) assertThat(componentInterface.newComponent().modelToRendererMapping.values) .containsOnly(testRenderer.kotlin) } } @Test fun `the component does not contain a binding for the renderer if it is annotated with @Inject`() { 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 import me.tatarka.inject.annotations.Inject class Model : BaseModel @ContributesRenderer @Inject class TestRenderer(@Suppress("unused") val string: String) : 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) assertThat(generatedComponent.declaredNonSyntheticMethods.map { it.name }) .containsOnly( "provideSoftwareAmazonTestTestRendererModel", "provideSoftwareAmazonTestTestRendererModelKey", ) assertThat(componentInterface.newComponent().renderers.keys) .containsOnly(model) } } @Test fun `when using @SingleIn(RendererScope_class) then a warning is printed`() { 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 import software.amazon.app.platform.renderer.RendererScope import me.tatarka.inject.annotations.Inject import software.amazon.lastmile.kotlin.inject.anvil.SingleIn class Model : BaseModel @Inject @SingleIn(RendererScope::class) @ContributesRenderer class TestRenderer(@Suppress("unused") val string: String) : Renderer { override fun render(model: Model) = Unit } """, componentInterfaceSource, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Source0.kt:15: Renderers should not be singletons in the " + "RendererScope. The RendererFactory will cache the Renderer when " + "necessary. Remove the @SingleIn(RendererScope::class) annotation." ) } } @Test fun `it is redundant to add @Inject for a zero arg constructor`() { 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 import me.tatarka.inject.annotations.Inject class Model : BaseModel @ContributesRenderer @Inject class TestRenderer : Renderer { override fun render(model: Model) = Unit } """, componentInterfaceSource, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "It's redundant to use @Inject when using @ContributesRenderer " + "for a Renderer with a zero-arg constructor." ) } } @Test fun `it is required to use @Inject for a non-zero arg constructor`() { 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 @ContributesRenderer class TestRenderer(@Suppress("unused") val string: String) : Renderer { override fun render(model: Model) = Unit } """, componentInterfaceSource, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "When using @ContributesRenderer and you need to inject types " + "in the constructor, then it's necessary to add the @Inject annotation." ) } } @Language("kotlin") private val componentInterfaceSource = """ package software.amazon.test import software.amazon.app.platform.renderer.RendererComponent import software.amazon.app.platform.renderer.RendererScope import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Provides import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent import software.amazon.lastmile.kotlin.inject.anvil.SingleIn @Component @MergeComponent(RendererScope::class) @SingleIn(RendererScope::class) abstract class ComponentInterface : ComponentInterfaceMerged, RendererComponent { @Provides fun provideString(): String = "abc" } """ private val JvmCompilationResult.testRenderer: Class<*> get() = classLoader.loadClass("software.amazon.test.TestRenderer") private val JvmCompilationResult.model: KClass get() = classLoader.loadClass("software.amazon.test.Model").kotlin private val JvmCompilationResult.presenter: Class<*> get() = classLoader.loadClass("software.amazon.test.Presenter") private val Class<*>.model: Class<*> get() = classes.single { it.simpleName == "Model" } private val Class<*>.model1: Class<*> get() = classes.single { it.simpleName == "Model1" } private val Class<*>.model2: Class<*> get() = classes.single { it.simpleName == "Model2" } private val Class<*>.rendererComponent: Class<*> get() = classLoader.loadClass( "$APP_PLATFORM_LOOKUP_PACKAGE.$packageName." + canonicalName.substringAfter(packageName).substring(1).replace(".", "") + "Component" ) private val Class<*>.defaultImpl: Class<*> get() = classLoader.loadClass("$canonicalName\$DefaultImpls") private val Class<*>.modelType: KClass<*> get() { // This reflection code is somewhat disgusting, but it works. Our processor generates // an interface with functions that have a default implementation. We load the class // for the default implementation that is an output of the Kotlin compiler. // // Then, in the class for default implementations we find the function that returns // the binding for map-multibindings, which is a Pair, Function0. // The function is static, but requires two parameters. We stub the parameters by // instantiating a Proxy instance. // // After invoking the function we get the actual Pair, ..>, which key is // the model type we're looking for. val defaultImpls = rendererComponent.defaultImpl val proxy = Proxy.newProxyInstance(classLoader, arrayOf(rendererComponent, Function0::class.java)) { _, _, _ -> throw NotImplementedError() } val mapBindingMethod = defaultImpls.methods.single { it.name == "provideSoftwareAmazonTestTestRendererModel" } return (mapBindingMethod.invoke(null, proxy, proxy) as Pair<*, *>).first as KClass<*> } } ================================================ FILE: kotlin-inject-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/processor/ContributesRobotGeneratorTest.kt ================================================ @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.inject.processor import assertk.assertThat import assertk.assertions.contains import assertk.assertions.containsOnly import assertk.assertions.isEmpty 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.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.capitalize import software.amazon.app.platform.ksp.isAnnotatedWith import software.amazon.app.platform.robot.RobotComponent import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo import software.amazon.lastmile.kotlin.inject.anvil.SingleIn class ContributesRobotGeneratorTest { @Test fun `a component interface is generated without @Inject constructor`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import software.amazon.lastmile.kotlin.inject.anvil.AppScope @ContributesRobot(AppScope::class) class TestRobot : Robot """, componentInterfaceSource, ) { val robotComponent = testRobot.component assertThat(robotComponent.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) assertThat(robotComponent.origin).isEqualTo(testRobot) with(robotComponent.declaredNonSyntheticMethods.single { it.name == "provideTestRobot" }) { assertThat(parameters).isEmpty() assertThat(returnType).isEqualTo(testRobot) assertThat(this).isAnnotatedWith(Provides::class) assertThat(getAnnotation(SingleIn::class.java)).isNull() } with( robotComponent.declaredNonSyntheticMethods.single { it.name == "provideTestRobotIntoMap" } ) { 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().robots.keys) .containsOnly(testRobot.kotlin) } } @Test fun `a component interface is generated with @Inject constructor`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import me.tatarka.inject.annotations.Inject import software.amazon.lastmile.kotlin.inject.anvil.AppScope @Inject @ContributesRobot(AppScope::class) class TestRobot : Robot """, componentInterfaceSource, ) { val robotComponent = testRobot.component assertThat(robotComponent.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) assertThat(robotComponent.origin).isEqualTo(testRobot) assertThat( robotComponent.declaredNonSyntheticMethods.singleOrNull { it.name == "provideTestRobot" } ) .isNull() with( robotComponent.declaredNonSyntheticMethods.single { it.name == "provideTestRobotIntoMap" } ) { 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().robots.keys) .containsOnly(testRobot.kotlin) } } @Test fun `a component interface is generated without direct super type`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import software.amazon.lastmile.kotlin.inject.anvil.AppScope interface BaseRobot1 : Robot abstract class BaseRobot2 : BaseRobot1 @ContributesRobot(AppScope::class) class TestRobot : BaseRobot2() """ ) { assertThat(testRobot.component).isNotNull() } } @Test fun `the robot class must be a super type`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import software.amazon.lastmile.kotlin.inject.anvil.AppScope interface BaseRobot1 abstract class BaseRobot2 : BaseRobot1 @ContributesRobot(AppScope::class) class TestRobot : BaseRobot2() """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "In order to use @ContributesRobot, TestRobot must implement " + "software.amazon.app.platform.robot.Robot." ) } } @Test fun `a Robot must not be a singleton`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import me.tatarka.inject.annotations.Inject import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.SingleIn @Inject @SingleIn(AppScope::class) @ContributesRobot(AppScope::class) class TestRobot : Robot """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "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 @SingleIn annotation." ) } } @Test fun `only the app scope is supported for now`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import software.amazon.lastmile.kotlin.inject.anvil.AppScope @ContributesRobot(String::class) class TestRobot : Robot """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Robots can only be contributed to the AppScope for now. " + "Scope kotlin.String is unsupported." ) } } @Language("kotlin") private val componentInterfaceSource = """ package software.amazon.test import software.amazon.app.platform.renderer.RendererComponent import me.tatarka.inject.annotations.Component import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent import software.amazon.lastmile.kotlin.inject.anvil.SingleIn @Component @MergeComponent(AppScope::class, exclude = [RendererComponent::class]) @SingleIn(AppScope::class) interface ComponentInterface : ComponentInterfaceMerged """ private val JvmCompilationResult.testRobot: Class<*> get() = classLoader.loadClass("software.amazon.test.TestRobot") private val Class<*>.component: Class<*> get() = classLoader.loadClass( "$APP_PLATFORM_LOOKUP_PACKAGE.$packageName." + canonicalName.substringAfter(packageName).substring(1).split(".").joinToString( separator = "" ) { it.capitalize() } + "Component" ) } ================================================ FILE: kotlin-inject-extensions/contribute/public/api/android/public.api ================================================ public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesMockImpl : java/lang/annotation/Annotation { public abstract fun boundType ()Ljava/lang/Class; public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesMockImpl$Container : java/lang/annotation/Annotation { public abstract fun value ()[Lsoftware/amazon/app/platform/inject/mock/ContributesMockImpl; } public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesRealImpl : java/lang/annotation/Annotation { public abstract fun boundType ()Ljava/lang/Class; public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesRealImpl$Container : java/lang/annotation/Annotation { public abstract fun value ()[Lsoftware/amazon/app/platform/inject/mock/ContributesRealImpl; } public abstract interface annotation class software/amazon/app/platform/inject/mock/MockMode : java/lang/annotation/Annotation { } public abstract interface annotation class software/amazon/app/platform/inject/mock/RealImpl : java/lang/annotation/Annotation { } ================================================ FILE: kotlin-inject-extensions/contribute/public/api/desktop/public.api ================================================ public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesMockImpl : java/lang/annotation/Annotation { public abstract fun boundType ()Ljava/lang/Class; public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesMockImpl$Container : java/lang/annotation/Annotation { public abstract fun value ()[Lsoftware/amazon/app/platform/inject/mock/ContributesMockImpl; } public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesRealImpl : java/lang/annotation/Annotation { public abstract fun boundType ()Ljava/lang/Class; public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/inject/mock/ContributesRealImpl$Container : java/lang/annotation/Annotation { public abstract fun value ()[Lsoftware/amazon/app/platform/inject/mock/ContributesRealImpl; } public abstract interface annotation class software/amazon/app/platform/inject/mock/MockMode : java/lang/annotation/Annotation { } public abstract interface annotation class software/amazon/app/platform/inject/mock/RealImpl : java/lang/annotation/Annotation { } ================================================ FILE: kotlin-inject-extensions/contribute/public/build.gradle ================================================ plugins { id 'software.amazon.app.platform.lib' } appPlatformBuildSrc { enableKotlinInject true enablePublishing true } ================================================ FILE: kotlin-inject-extensions/contribute/public/src/commonMain/kotlin/software/amazon/app/platform/inject/mock/ContributesMockImpl.kt ================================================ package software.amazon.app.platform.inject.mock import kotlin.reflect.KClass import software.amazon.app.platform.scope.Scoped import software.amazon.lastmile.kotlin.inject.anvil.extend.ContributingAnnotation /** * Used to contribute a mocked implementation to a given interface that has a real implementation as * well. * * ``` * @ContributesMockImpl(AppScope::class) * @Inject * @SingleIn(AppScope::class) * class MockVts : Vts * ``` * * This annotation will generate the following kotlin-inject component: * ``` * @ContributesTo(AppScope::class) * interface MockVtsMockComponent { * @Provides * fun provideTestVts( * @MockMode mockMode: Boolean, * mockVts: () -> MockVts, * @RealImpl realVts: () -> Vts, * ): Vts = if (mockMode) mockVts() else realVts() * } * ``` * * This annotation is also repeatable: * ``` * @ContributesMockImpl(AppScope::class, boundType = Vts::class) * @ContributesMockImpl(AppScope::class, boundType = Vts2::class) * class MockVts : Vts, Vts2 * ``` * * It is safe to implement the [Scoped] interface. [Scoped.onEnterScope] and [Scoped.onExitScope] * will only be called if the mock implementation is used at runtime: * ``` * @ContributesMockImpl(AppScope::class) * @Inject * @SingleIn(AppScope::class) * class MockVts : Vts, Scoped * ``` */ @Repeatable @ContributingAnnotation public annotation class ContributesMockImpl( /** The scope in which to include this contributed binding. */ val scope: KClass<*>, /** * The type that this class is bound to, this is required when there is more than a single * superType or the superType is not an interface. */ val boundType: KClass<*> = Unit::class, ) ================================================ FILE: kotlin-inject-extensions/contribute/public/src/commonMain/kotlin/software/amazon/app/platform/inject/mock/ContributesRealImpl.kt ================================================ package software.amazon.app.platform.inject.mock import kotlin.reflect.KClass import software.amazon.app.platform.scope.Scoped import software.amazon.lastmile.kotlin.inject.anvil.extend.ContributingAnnotation /** * Used to contribute a real implementation to a given interface that has a mocked implementation as * well. * * ``` * @ContributesRealImpl(AppScope::class) * @Inject * @SingleIn(AppScope::class) * class RealVts : Vts * ``` * * This annotation will generate the following kotlin-inject component: * ``` * @ContributesTo(AppScope::class) * interface RealVtsRealImplComponent { * @Provides * @RealImpl * fun provideVts(realVts: RealVts): Vts = realVts * } * ``` * * This annotation is also repeatable, where for each bound type a provider method will be * generated: * ``` * @ContributesRealImpl(AppScope::class, boundType = Vts::class) * @ContributesRealImpl(AppScope::class, boundType = Vts2::class) * class RealVts : Vts, Vts2 * ``` * * It is safe to implement the [Scoped] interface. [Scoped.onEnterScope] and [Scoped.onExitScope] * will only be called if the real implementation is used at runtime: * ``` * @ContributesRealImpl(AppScope::class) * @Inject * @SingleIn(AppScope::class) * class RealVts : Vts, Scoped * ``` */ @Repeatable @ContributingAnnotation public annotation class ContributesRealImpl( /** The scope in which to include this contributed binding. */ val scope: KClass<*>, /** * The type that this class is bound to, this is required when there is more than a single * superType or the superType is not an interface. */ val boundType: KClass<*> = Unit::class, ) ================================================ FILE: kotlin-inject-extensions/contribute/public/src/commonMain/kotlin/software/amazon/app/platform/inject/mock/MockMode.kt ================================================ package software.amazon.app.platform.inject.mock 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 /** * Annotation to produce a Boolean that signals if available mocked implementations should be used. * * Example of @MockMode being used to decide the implementation to use for ExampleService: * ``` * @Provide * fun provideExampleService ( * realService: () -> RealExampleService, * mockService: () -> FakeExampleService, * @MockMode mockMode: Boolean, * ): ExampleService { * return if (mockMode) mockService() else realService() * } * ``` */ @Qualifier @Retention(RUNTIME) @Target(CLASS, FUNCTION, PROPERTY_GETTER, VALUE_PARAMETER, TYPE, PROPERTY) public annotation class MockMode ================================================ FILE: kotlin-inject-extensions/contribute/public/src/commonMain/kotlin/software/amazon/app/platform/inject/mock/RealImpl.kt ================================================ package software.amazon.app.platform.inject.mock 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 /** * A qualifier that is used when generating a binds method using [ContributesRealImpl] to denote the * realImpl of an interface. * * This annotation should not be used directly and only used within ContributesRealImplGenerator and * ContributesMockImplGenerator. */ @Qualifier @Retention(RUNTIME) @Target(CLASS, FUNCTION, PROPERTY_GETTER, VALUE_PARAMETER, TYPE, PROPERTY) public annotation class RealImpl ================================================ FILE: ksp-common/public/build.gradle ================================================ plugins { id 'software.amazon.app.platform.lib.jvm' } appPlatformBuildSrc { enablePublishing true } dependencies { api libs.ksp.api api libs.kotlin.poet api libs.kotlin.poet.ksp // Gives us access to annotations. api project(':di-common:public') api project(':scope:public') } // We don't need the apiCheck in this module. tasks.named('apiCheck').configure { it.enabled = false } tasks.named('apiDump').configure { it.enabled = false } ================================================ FILE: ksp-common/public/src/main/kotlin/software/amazon/app/platform/ksp/CompositeSymbolProcessor.kt ================================================ package software.amazon.app.platform.ksp import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.symbol.KSAnnotated public class CompositeSymbolProcessor(vararg symbolProcessors: SymbolProcessor) : SymbolProcessor { private val symbolProcessors = symbolProcessors.sortedBy { it::class.qualifiedName } override fun process(resolver: Resolver): List { return symbolProcessors.flatMap { it.process(resolver) } } override fun finish() { symbolProcessors.forEach { it.finish() } } override fun onError() { symbolProcessors.forEach { it.onError() } } } ================================================ FILE: ksp-common/public/src/main/kotlin/software/amazon/app/platform/ksp/ContextAware.kt ================================================ package software.amazon.app.platform.ksp import com.google.devtools.ksp.getVisibility import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSFile import com.google.devtools.ksp.symbol.KSNode import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSTypeAlias import com.google.devtools.ksp.symbol.Visibility import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.asClassName import kotlin.reflect.KClass import software.amazon.app.platform.scope.Scoped @Suppress("TooManyFunctions") public interface ContextAware { public val logger: KSPLogger private val anyFqName get() = Any::class.requireQualifiedName() public val scopedFqName: String get() = Scoped::class.requireQualifiedName() public val scopedClassName: ClassName get() = Scoped::class.asClassName() public fun requireNotNull(value: T?, symbol: KSNode?, lazyMessage: () -> String): T { if (value == null) { val message = lazyMessage() logger.error(message, symbol) throw IllegalArgumentException(message) } return value } public fun check(condition: Boolean, symbol: KSNode?, lazyMessage: () -> String) { if (!condition) { val message = lazyMessage() logger.error(message, symbol) throw IllegalStateException(message) } } public fun checkIsPublic(clazz: KSClassDeclaration) { check(clazz.getVisibility() == Visibility.PUBLIC, clazz) { "Contributed component interfaces must be public." } } public fun checkHasScope(clazz: KSClassDeclaration) { // Ensures that the value is non-null. clazz.scope() } public fun KSClassDeclaration.scope(): MergeScope { return requireNotNull(scopeOrNull(), this) { "Couldn't find scope for $this." } } private fun KSClassDeclaration.scopeOrNull(): MergeScope? { val annotationsWithScopeParameter = annotations .filter { it.hasScopeParameter() } .toList() .ifEmpty { return null } return scopeForAnnotationsWithScopeParameters(this, annotationsWithScopeParameter) } private fun KSAnnotation.hasScopeParameter(): Boolean { return (annotationType.resolve().declaration as? KSClassDeclaration) ?.primaryConstructor ?.parameters ?.firstOrNull() ?.name ?.asString() == "scope" } private fun scopeForAnnotationsWithScopeParameters( clazz: KSClassDeclaration, annotations: List, ): MergeScope { val explicitScopes = annotations.map { annotation -> annotation.scopeParameter() } explicitScopes.scan(explicitScopes.first().declaration.requireQualifiedName()) { previous, next -> check(previous == next.declaration.requireQualifiedName(), clazz) { "All scopes on annotations must be the same." } previous } return MergeScope(explicitScopes.first()) } private fun KSAnnotation.scopeParameter(): KSType { return requireNotNull(scopeParameterOrNull(), this) { "Couldn't find a scope parameter." } } private fun KSAnnotation.scopeParameterOrNull(): KSType? { return arguments.firstOrNull { it.name?.asString() == "scope" }?.let { it.value as? KSType } } public fun KSClassDeclaration.findAnnotation(annotation: KClass): KSAnnotation = findAnnotations(annotation).single() public fun KSClassDeclaration.findAnnotations( annotation: KClass ): List { val fqName = annotation.requireQualifiedName() return annotations .filter { it.isAnnotation(fqName) } .toList() .also { check(it.isNotEmpty(), this) { "Couldn't find the @${annotation.simpleName} annotation for $this." } } } public fun KSAnnotation.isAnnotation(fqName: String): Boolean { return annotationType.resolve().declaration.requireQualifiedName() == fqName } public fun KSDeclaration.requireContainingFile(): KSFile = requireNotNull(containingFile, this) { "Containing file was null for $this" } public fun KSDeclaration.requireQualifiedName(): String = requireNotNull(qualifiedName?.asString(), this) { "Qualified name was null for $this" } public fun KClass<*>.requireQualifiedName(): String = requireNotNull(qualifiedName) { "Qualified name was null for $this" } public fun Resolver.getSymbolsWithAnnotation(annotation: KClass<*>): Sequence = getSymbolsWithAnnotation(annotation.requireQualifiedName()) public fun KSDeclaration.innerClassNames(separator: String = ""): String { val classNames = requireQualifiedName().substring(packageName.asString().length + 1) return classNames.replace(".", separator) } public fun KSType.isScoped(): Boolean { return declaration.requireQualifiedName() == scopedFqName || (declaration as? KSTypeAlias)?.type?.resolve()?.declaration?.requireQualifiedName() == scopedFqName } @Suppress("ReturnCount") public fun boundType(clazz: KSClassDeclaration, annotation: KSAnnotation): KSType { boundTypeFromAnnotation(annotation)?.let { return it } // The bound type is not defined in the annotation, let's inspect the super types. val superTypes = clazz.superTypes .map { it.resolve() } .filter { it.declaration.requireQualifiedName() != anyFqName } .toList() when (superTypes.size) { 0 -> { val message = "The bound type could not be determined for " + "${clazz.simpleName.asString()}. There are no super types." logger.error(message, clazz) throw IllegalArgumentException(message) } 1 -> { return superTypes.single() } else -> { if (superTypes.size == 2) { // Ignore Scoped as super type. superTypes .singleOrNull { !it.isScoped() } ?.let { return it } } val message = "The bound type could not be determined for " + "${clazz.simpleName.asString()}. There are multiple super types: " + superTypes.joinToString { it.declaration.simpleName.asString() } + "." logger.error(message, clazz) throw IllegalArgumentException(message) } } } public fun boundTypeFromAnnotation(annotation: KSAnnotation): KSType? { return annotation.arguments .firstOrNull { it.name?.asString() == "boundType" } ?.let { it.value as? KSType } ?.takeIf { it.declaration.requireQualifiedName() != Unit::class.requireQualifiedName() } } public fun checkNoDuplicateBoundTypes( clazz: KSClassDeclaration, annotations: List, ) { annotations .mapNotNull { boundTypeFromAnnotation(it) } .map { it.declaration.requireQualifiedName() } .takeIf { it.isNotEmpty() } ?.reduce { previous, next -> check(previous != next, clazz) { "The same type should not be contributed twice: $next." } previous } } public fun KSClassDeclaration.findAnnotationsAtLeastOne( annotation: KClass ): List { return findAnnotations(annotation).also { check(it.isNotEmpty(), this) { "Couldn't find the @${annotation.simpleName} annotation for $this." } } } /** Return `software.amazon.Test` into `ComAmazonTest`. */ public val KSClassDeclaration.safeClassName: String get() = requireQualifiedName().split(".").joinToString(separator = "") { it.capitalize() } } ================================================ FILE: ksp-common/public/src/main/kotlin/software/amazon/app/platform/ksp/MergeScope.kt ================================================ package software.amazon.app.platform.ksp import com.google.devtools.ksp.symbol.KSType /** * Represents the destination of contributed types and which types should be merged during the merge * phase, e.g. * * ``` * @ContributesTo(AppScope::class) * interface ContributedComponentInterface * * @Component * @MergeComponent(AppScope::class) * interface MergedComponent * ``` * * Where `AppScope` would represent the "MergeScope". */ public data class MergeScope(val type: KSType) ================================================ FILE: ksp-common/public/src/main/kotlin/software/amazon/app/platform/ksp/Util.kt ================================================ package software.amazon.app.platform.ksp import com.google.devtools.ksp.isDefault import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSValueArgument import java.util.Locale public fun String.decapitalize(): String = replaceFirstChar { it.lowercase(Locale.US) } public fun String.capitalize(): String = replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() } public inline fun KSAnnotation.argumentOfTypeAt( context: ContextAware, name: String, ): T? { return argumentOfTypeWithMapperAt(context, name) { _, value -> value } } public inline fun KSAnnotation.argumentOfTypeWithMapperAt( context: ContextAware, name: String, mapper: (arg: KSValueArgument, value: T) -> R, ): R? { return argumentAt(name)?.let { arg -> val value = arg.value context.check(value is T, arg) { "Expected argument '$name' of type '${T::class.qualifiedName} but was '${arg.javaClass.name}'." } (value as T)?.let { mapper(arg, it) } } } public fun KSAnnotation.argumentAt(name: String): KSValueArgument? { return arguments.find { it.name?.asString() == name }?.takeUnless { it.isDefault() } } ================================================ FILE: ksp-common/testing/build.gradle ================================================ plugins { id 'software.amazon.app.platform.lib.jvm' } dependencies { api libs.assertk api libs.kotlin.compile.testing.core } ================================================ FILE: ksp-common/testing/src/main/kotlin/software/amazon/app/platform/ksp/CommonSourceCode.kt ================================================ package software.amazon.app.platform.ksp val Class<*>.inner: Class<*> get() = classes.single { it.simpleName == "Inner" } ================================================ FILE: ksp-common/testing/src/main/kotlin/software/amazon/app/platform/ksp/Util.kt ================================================ @file:JvmName("UtilUnitTest") @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.ksp import assertk.Assert import assertk.assertions.contains import assertk.assertions.doesNotContain import assertk.assertions.isEqualTo import com.tschuchort.compiletesting.KotlinCompilation.ExitCode import java.lang.reflect.AnnotatedElement import kotlin.reflect.KClass import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi fun Assert.isAnnotatedWith(annotation: KClass<*>) { transform { element -> element.annotations.map { it.annotationClass } }.contains(annotation) } fun Assert.isOk() { isEqualTo(ExitCode.OK) } fun Assert.isError() { transform { element -> when (element) { ExitCode.OK -> element ExitCode.INTERNAL_ERROR, ExitCode.COMPILATION_ERROR, ExitCode.SCRIPT_EXECUTION_ERROR -> ExitCode.COMPILATION_ERROR } } .isEqualTo(ExitCode.COMPILATION_ERROR) } fun Assert.isNotAnnotatedWith(annotation: KClass<*>) { transform { element -> element.annotations.map { it.annotationClass } }.doesNotContain(annotation) } ================================================ FILE: metro/impl/api/android/impl.api ================================================ public abstract interface class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph { public fun providePresenterCoroutineScope (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$DefaultImpls { public static fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public abstract interface class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$MetroContributionToAppScope : software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph { } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$MetroContributionToAppScope$DefaultImpls { public static fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$MetroContributionToAppScope;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$ProvidePresenterCoroutineScopeMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$ProvidePresenterCoroutineScopeMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineScope; public final fun mirrorFunction (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$ProvidePresenterCoroutineScopeMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Ldev/zacsweers/metro/internal/Factory; public final fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph { 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/metro/AppScopeCoroutineScopeGraph$DefaultImpls { public static fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; public static fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope : software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph { } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope$DefaultImpls { public static fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; public static fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppCoroutineScopeMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppCoroutineScopeMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineScope; public final fun mirrorFunction (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppCoroutineScopeMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;)Ldev/zacsweers/metro/internal/Factory; public final fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppScopeCoroutineScopeScopedMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppScopeCoroutineScopeScopedMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; public final fun mirrorFunction (Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppScopeCoroutineScopeScopedMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;)Ldev/zacsweers/metro/internal/Factory; public final fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph { 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/metro/CoroutineDispatcherGraph$DefaultImpls { public static fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope : software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph { } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope$DefaultImpls { public static fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope;)Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideDefaultCoroutineDispatcherMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideDefaultCoroutineDispatcherMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun mirrorFunction ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideDefaultCoroutineDispatcherMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Ldev/zacsweers/metro/internal/Factory; public final fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideIoCoroutineDispatcherMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideIoCoroutineDispatcherMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun mirrorFunction ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideIoCoroutineDispatcherMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Ldev/zacsweers/metro/internal/Factory; public final fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideMainCoroutineDispatcherMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideMainCoroutineDispatcherMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun mirrorFunction ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideMainCoroutineDispatcherMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Ldev/zacsweers/metro/internal/Factory; public final fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } ================================================ FILE: metro/impl/api/desktop/impl.api ================================================ public abstract interface class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph { public fun providePresenterCoroutineScope (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$DefaultImpls { public static fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public abstract interface class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$MetroContributionToAppScope : software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph { } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$MetroContributionToAppScope$DefaultImpls { public static fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$MetroContributionToAppScope;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$ProvidePresenterCoroutineScopeMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$ProvidePresenterCoroutineScopeMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineScope; public final fun mirrorFunction (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph$ProvidePresenterCoroutineScopeMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Ldev/zacsweers/metro/internal/Factory; public final fun providePresenterCoroutineScope (Lsoftware/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lkotlinx/coroutines/CoroutineScope; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph { 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/metro/AppScopeCoroutineScopeGraph$DefaultImpls { public static fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; public static fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope : software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph { } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope$DefaultImpls { public static fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; public static fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$MetroContributionToAppScope;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppCoroutineScopeMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppCoroutineScopeMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineScope; public final fun mirrorFunction (Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppCoroutineScopeMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;)Ldev/zacsweers/metro/internal/Factory; public final fun provideAppCoroutineScope (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped;)Lkotlinx/coroutines/CoroutineScope; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppScopeCoroutineScopeScopedMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppScopeCoroutineScopeScopedMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; public final fun mirrorFunction (Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public final class software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph$ProvideAppScopeCoroutineScopeScopedMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Ldev/zacsweers/metro/Provider;)Ldev/zacsweers/metro/internal/Factory; public final fun provideAppScopeCoroutineScopeScoped (Lsoftware/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph;Lkotlinx/coroutines/CoroutineDispatcher;)Lsoftware/amazon/app/platform/scope/coroutine/CoroutineScopeScoped; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph { 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/metro/CoroutineDispatcherGraph$DefaultImpls { public static fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } public abstract interface class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope : software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph { } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope$DefaultImpls { public static fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope;)Lkotlinx/coroutines/CoroutineDispatcher; public static fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$MetroContributionToAppScope;)Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideDefaultCoroutineDispatcherMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideDefaultCoroutineDispatcherMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun mirrorFunction ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideDefaultCoroutineDispatcherMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Ldev/zacsweers/metro/internal/Factory; public final fun provideDefaultCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideIoCoroutineDispatcherMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideIoCoroutineDispatcherMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun mirrorFunction ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideIoCoroutineDispatcherMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Ldev/zacsweers/metro/internal/Factory; public final fun provideIoCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideMainCoroutineDispatcherMetroFactory : dev/zacsweers/metro/internal/Factory { public static final field Companion Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideMainCoroutineDispatcherMetroFactory$Companion; public synthetic fun (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun invoke ()Ljava/lang/Object; public final fun invoke ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun mirrorFunction ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph$ProvideMainCoroutineDispatcherMetroFactory$Companion { public final fun create (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Ldev/zacsweers/metro/internal/Factory; public final fun provideMainCoroutineDispatcher (Lsoftware/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph;)Lkotlinx/coroutines/CoroutineDispatcher; } ================================================ FILE: metro/impl/build.gradle ================================================ plugins { id 'software.amazon.app.platform.lib' } appPlatformBuildSrc { enableMetro true enablePublishing true } dependencies { commonMainApi project(':scope:public') } ================================================ FILE: metro/impl/src/commonMain/kotlin/software/amazon/app/platform/presenter/metro/PresenterCoroutineScopeGraph.kt ================================================ package software.amazon.app.platform.presenter.metro import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Provides import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.plus import software.amazon.app.platform.presenter.PresenterCoroutineScope import software.amazon.app.platform.scope.coroutine.MainCoroutineDispatcher /** Provides the coroutine scope to run presenters. */ @ContributesTo(AppScope::class) public interface PresenterCoroutineScopeGraph { /** * 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: metro/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/metro/AppScopeCoroutineScopeGraph.kt ================================================ package software.amazon.app.platform.scope.coroutine.metro import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import software.amazon.app.platform.scope.coroutine.CoroutineScopeScoped import software.amazon.app.platform.scope.coroutine.IoCoroutineDispatcher /** Graph providing coroutine scopes in the App scope. */ @ContributesTo(AppScope::class) public interface AppScopeCoroutineScopeGraph { /** * 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: metro/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/metro/CoroutineDispatcherGraph.kt ================================================ package software.amazon.app.platform.scope.coroutine.metro import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import software.amazon.app.platform.scope.coroutine.DefaultCoroutineDispatcher import software.amazon.app.platform.scope.coroutine.IoCoroutineDispatcher import software.amazon.app.platform.scope.coroutine.MainCoroutineDispatcher /** Provides default dispatchers for coroutine scopes. */ @ContributesTo(AppScope::class) public interface CoroutineDispatcherGraph { /** 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: metro/impl/src/commonMain/kotlin/software/amazon/app/platform/scope/coroutine/metro/IoDispatcher.kt ================================================ package software.amazon.app.platform.scope.coroutine.metro import kotlinx.coroutines.CoroutineDispatcher /** Expect declaration for the IO dispatcher, because it doesn't exist for WASM. */ internal expect val ioDispatcher: CoroutineDispatcher ================================================ FILE: metro/impl/src/noWasmJsMain/kotlin/software/amazon/app/platform/scope/coroutine/metro/IoDispatcher.kt ================================================ package software.amazon.app.platform.scope.coroutine.metro 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: metro/impl/src/wasmJsMain/kotlin/software/amazon/app/platform/scope/coroutine/metro/IoDispatcher.kt ================================================ package software.amazon.app.platform.scope.coroutine.metro 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: metro/public/api/android/public.api ================================================ public abstract interface annotation class software/amazon/app/platform/inject/metro/ContributesScoped : java/lang/annotation/Annotation { public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/renderer/metro/RendererKey : java/lang/annotation/Annotation { public abstract fun value ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/renderer/metro/RobotKey : java/lang/annotation/Annotation { public abstract fun value ()Ljava/lang/Class; } public final class software/amazon/app/platform/scope/di/metro/MetroServiceKt { public static final field METRO_DEPENDENCY_GRAPH_KEY Ljava/lang/String; public static final fun addMetroDependencyGraph (Lsoftware/amazon/app/platform/scope/Scope$Builder;Ljava/lang/Object;)V } ================================================ FILE: metro/public/api/desktop/public.api ================================================ public abstract interface annotation class software/amazon/app/platform/inject/metro/ContributesScoped : java/lang/annotation/Annotation { public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/renderer/metro/RendererKey : java/lang/annotation/Annotation { public abstract fun value ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/app/platform/renderer/metro/RobotKey : java/lang/annotation/Annotation { public abstract fun value ()Ljava/lang/Class; } public final class software/amazon/app/platform/scope/di/metro/MetroServiceKt { public static final field METRO_DEPENDENCY_GRAPH_KEY Ljava/lang/String; public static final fun addMetroDependencyGraph (Lsoftware/amazon/app/platform/scope/Scope$Builder;Ljava/lang/Object;)V } ================================================ FILE: metro/public/build.gradle ================================================ plugins { id 'software.amazon.app.platform.lib' } appPlatformBuildSrc { enablePublishing true } dependencies { commonMainApi project(':presenter:public') commonMainApi project(':scope:public') commonMainImplementation libs.metro.runtime commonTestImplementation project(':internal:testing') } ================================================ FILE: metro/public/src/commonMain/kotlin/software/amazon/app/platform/inject/metro/ContributesScoped.kt ================================================ package software.amazon.app.platform.inject.metro import kotlin.annotation.AnnotationTarget.CLASS import kotlin.reflect.KClass import software.amazon.app.platform.scope.Scoped /** * Used to contribute a class implementing the [Scoped] interface to the given [scope], e.g. * * ``` * @Inject * @SingleIn(AppScope::class) * @ContributesScoped(AppScope::class) * class MyClass(..) : SuperType, Scoped * ``` * * This annotation is a shortcut for using `@ContributesBinding` and `@ContributesIntoSet`, but with * a qualifier for the multibinding alone. This can in Metro only be expressed with a contributed * graph: * ``` * @Inject * @SingleIn(AppScope::class) * class MyClass(..) : SuperType, Scoped * * @ContributesTo(AppScope::class) * interface MyClassGraph { * @Binds val MyClass.bindSuperType: SuperType * * @Binds @IntoSet @ForScope(AppScope::class) val MyClass.bindScoped: Scoped * } * ``` * * Note that this annotation is only applicable for Metro and not kotlin-inject, because for * kotlin-inject we provide a custom code generator out of the box when using `@ContributesBinding` * that can handle the [Scoped] multibinding interface. */ @Target(CLASS) public annotation class ContributesScoped( /** The scope in which to include this contributed binding. */ val scope: KClass<*> ) ================================================ FILE: metro/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/metro/RendererKey.kt ================================================ package software.amazon.app.platform.renderer.metro import dev.zacsweers.metro.MapKey import kotlin.reflect.KClass import software.amazon.app.platform.presenter.BaseModel /** * DO NOT USE DIRECTLY. * * This is a multibindings key used in Metro for identifying renderers by their model type. This key * is used by our custom code generator for `@ContributesRenderer`. [value] refers to the concrete * [BaseModel] handled by the renderer. */ @MapKey public annotation class RendererKey(val value: KClass) ================================================ FILE: metro/public/src/commonMain/kotlin/software/amazon/app/platform/renderer/metro/RobotKey.kt ================================================ package software.amazon.app.platform.renderer.metro import dev.zacsweers.metro.MapKey import kotlin.reflect.KClass /** * DO NOT USE DIRECTLY. * * This is a multibindings key used in Metro for identifying robots by their type. This key is used * by our custom code generator for `@ContributesRobot`. [value] refers to the concrete `Robot` * type. */ @MapKey public annotation class RobotKey(val value: KClass<*>) ================================================ FILE: metro/public/src/commonMain/kotlin/software/amazon/app/platform/scope/di/metro/MetroService.kt ================================================ package software.amazon.app.platform.scope.di.metro import software.amazon.app.platform.scope.Scope import software.amazon.app.platform.scope.parents @PublishedApi internal const val METRO_DEPENDENCY_GRAPH_KEY: String = "metroDependencyGraph" /** * Provides the Metro dependency graph that has been added to this [Scope]. A common pattern is to * use this function to look up graph interfaces in static contexts like test methods, static * functions or where constructor injection cannot be used, e.g. * * ``` * interface HudGraph { * val hudManager: HudManager * } * * rootScope.metroDependencyGraph().hudManager * ``` * * The given graph type [T] of the DI graph can be provided by this scope or a parent scope. */ public inline fun Scope.metroDependencyGraph(): T { parents(includeSelf = true) .firstNotNullOfOrNull { scope -> scope.getService(METRO_DEPENDENCY_GRAPH_KEY) as? T } ?.let { return it } val diGraphs = parents(includeSelf = true) .map { it.getService(METRO_DEPENDENCY_GRAPH_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 dependency graph implementing ${T::class}. Inspected: " + "[${diGraphs.joinToString { it.simpleName.toString() }}] (fully qualified " + "names: [${diGraphs.joinToString { it.toString().replace('\$', '.') }}])" ) } /** * Adds the given [dependencyGraph] to this builder. The instance can be later retrieved with * [metroDependencyGraph]. */ public fun Scope.Builder.addMetroDependencyGraph(dependencyGraph: Any) { addService(METRO_DEPENDENCY_GRAPH_KEY, dependencyGraph) } ================================================ FILE: metro/public/src/commonTest/kotlin/software/amazon/app/platform/scope/di/metro/MetroServiceTest.kt ================================================ package software.amazon.app.platform.scope.di.metro 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 MetroServiceTest { @Test fun `a metro graph can be registered in a scope`() { val graph = ParentGraphImpl() val scope = Scope.buildRootScope { addMetroDependencyGraph(graph) } assertThat(scope.metroDependencyGraph()).isSameInstanceAs(graph) } @Test @IgnoreWasm fun `if a metro graph cannot be found then an exception is thrown with a helpful error message`() { val parentGraph = ParentGraphImpl() val childGraph = ChildGraphImpl() val parentScope = Scope.buildRootScope { addMetroDependencyGraph(parentGraph) } val childScope = parentScope.buildChild("child") { addMetroDependencyGraph(childGraph) } val exception = assertFailsWith { childScope.metroDependencyGraph() } val kotlinReflectWarning = when (platform) { Platform.JVM -> " (Kotlin reflection is not available)" Platform.Native, Platform.Web -> "" } assertThat(exception) .hasMessage( "Couldn't find dependency graph implementing class kotlin.Unit$kotlinReflectWarning. " + "Inspected: [ChildGraphImpl, ParentGraphImpl] (fully qualified names: " + "[class software.amazon.app.platform.scope.di.metro.MetroServiceTest." + "ChildGraphImpl$kotlinReflectWarning, class software.amazon.app." + "platform.scope.di.metro.MetroServiceTest.ParentGraphImpl" + "$kotlinReflectWarning])" ) } @Test fun `a DI graph can be retrieved from a scope`() { val parentGraph = ParentGraphImpl() val childGraph = ChildGraphImpl() val parentScope = Scope.buildRootScope { addMetroDependencyGraph(parentGraph) } val childScope = parentScope.buildChild("child") { addMetroDependencyGraph(childGraph) } assertThat(childScope.metroDependencyGraph()).isSameInstanceAs(childGraph) assertThat(childScope.metroDependencyGraph()).isSameInstanceAs(parentGraph) assertThat(parentScope.metroDependencyGraph()).isSameInstanceAs(parentGraph) assertFailsWith { parentScope.metroDependencyGraph() } } private interface ParentGraph private class ParentGraphImpl : ParentGraph private interface ChildGraph private class ChildGraphImpl : ChildGraph } ================================================ FILE: metro-extensions/contribute/impl-code-generators/build.gradle ================================================ //file:noinspection UnnecessaryQualifiedReference plugins { id 'software.amazon.app.platform.lib.jvm' id 'com.google.devtools.ksp' } appPlatformBuildSrc { enablePublishing true } test { useJUnitPlatform() // Since Kotlin 2.0 we need more memory to run our tests. maxHeapSize = "2g" } dependencies { 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(':ksp-common:public') implementation project(':metro:public') implementation project(':scope:public') implementation libs.metro.runtime testImplementation project(':ksp-common:testing') testImplementation project(':metro:public') 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 compiler plugin is picked up in tests. testImplementation libs.metro.compiler // Bump transitive dependency. testImplementation libs.kotlin.compiler.embeddable testImplementation libs.ksp testImplementation libs.ksp.embeddable } // We don't need the apiCheck in this module. tasks.named('apiCheck').configure { it.enabled = false } tasks.named('apiDump').configure { it.enabled = false } // Configure the JVM target only for tests. We need 21 for unit tests, because they import the Metro compiler, // which requires 21. def jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 configurations.named('testCompileClasspath').configure { attributes.attribute( org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, jvmTarget.target.toInteger(), ) } configurations.named('testRuntimeClasspath').configure { attributes.attribute( org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, jvmTarget.target.toInteger(), ) } tasks.named('compileTestKotlin', org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile).configure { compilerOptions { it.jvmTarget.set(jvmTarget) } } tasks.named('compileTestJava', JavaCompile).configure { sourceCompatibility = jvmTarget.target targetCompatibility = jvmTarget.target javaCompiler = javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(jvmTarget.target) } } tasks.named('test', Test).configure { javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(jvmTarget.target) } } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/MetroContextAware.kt ================================================ package software.amazon.app.platform.metro import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSType import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Scope import software.amazon.app.platform.ksp.ContextAware internal interface MetroContextAware : ContextAware { val injectFqName get() = Inject::class.requireQualifiedName() private val scopeFqName get() = Scope::class.requireQualifiedName() fun KSAnnotation.isMetroScopeAnnotation(): Boolean { return annotationType.resolve().isMetroScopeAnnotation() } private fun KSType.isMetroScopeAnnotation(): Boolean { return declaration.annotations.any { // Don't use requireQualifiedName(), because @ContributingAnnotation might not be // on the compile classpath. it.annotationType.resolve().declaration.qualifiedName?.asString() == scopeFqName } } } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/MetroExtensionSymbolProcessorProvider.kt ================================================ package software.amazon.app.platform.metro 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.ksp.CompositeSymbolProcessor import software.amazon.app.platform.metro.processor.ContributesRendererProcessor import software.amazon.app.platform.metro.processor.ContributesRobotProcessor import software.amazon.app.platform.metro.processor.ContributesScopedProcessor /** Entry point for KSP to pick up our [SymbolProcessor]. */ @AutoService(SymbolProcessorProvider::class) @Suppress("unused") public class MetroExtensionSymbolProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return CompositeSymbolProcessor( ContributesRendererProcessor( codeGenerator = environment.codeGenerator, logger = environment.logger, ), ContributesRobotProcessor( codeGenerator = environment.codeGenerator, logger = environment.logger, ), ContributesScopedProcessor( codeGenerator = environment.codeGenerator, logger = environment.logger, ), ) } } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/Util.kt ================================================ package software.amazon.app.platform.metro import com.google.devtools.ksp.symbol.KSClassDeclaration import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.toClassName import dev.zacsweers.metro.Origin /** The package in which the App Platform extensions generate code. */ internal const val METRO_LOOKUP_PACKAGE = "app.platform.inject.metro" internal fun TypeSpec.Builder.addMetroOriginAnnotation( clazz: KSClassDeclaration ): TypeSpec.Builder = addAnnotation( AnnotationSpec.builder(Origin::class).addMember("%T::class", clazz.toClassName()).build() ) ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/processor/ContributesRendererProcessor.kt ================================================ package software.amazon.app.platform.metro.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.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 dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provider import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import kotlin.reflect.KClass import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE import software.amazon.app.platform.metro.MetroContextAware import software.amazon.app.platform.metro.addMetroOriginAnnotation import software.amazon.app.platform.renderer.metro.RendererKey /** * Generates the code for [ContributesRenderer]. * * In the lookup package [METRO_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 $METRO_LOOKUP_PACKAGE.software.amazon.test * * @ContributesTo(RendererScope::class) * interface TestRendererGraph { * @Provides * @IntoMap * @RendererKey(Model::class) * fun provideTestRendererIntoMap( * renderer: Provider, * ): Renderer<*> = renderer() * * @Provides * fun provideTestRenderer(): TestRenderer = TestRenderer() * * @Provides * @IntoMap * @RendererKey(Model::class) * @ForScope(RendererScope::class) * fun provideRendererModelKey(): KClass> = * TestRenderer::class * } * ``` */ internal class ContributesRendererProcessor( private val codeGenerator: CodeGenerator, override val logger: KSPLogger, ) : SymbolProcessor, MetroContextAware { 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 rendererKey = RendererKey::class.asClassName() 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 { generateGraphInterface(it) } return emptyList() } @OptIn(KspExperimental::class) private fun generateGraphInterface(clazz: KSClassDeclaration) { val packageName = "${METRO_LOOKUP_PACKAGE}.${clazz.packageName.asString()}" val graphClassName = ClassName(packageName, "${clazz.innerClassNames()}Graph") 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(graphClassName) .addType( TypeSpec.interfaceBuilder(graphClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addMetroOriginAnnotation(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) .addAnnotation( AnnotationSpec.builder(rendererKey).addMember("%T::class", modelType.toClassName()).build() ) .addParameter( name = "renderer", type = Provider::class.asClassName().parameterizedBy(clazz.toClassName()), ) .returns(rendererWildcard) .addStatement("return renderer()") .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(rendererKey).addMember("%T::class", modelType.toClassName()).build() ) .addAnnotation( AnnotationSpec.builder(ForScope::class) .addMember("scope = %T::class", rendererScope) .build() ) .returns( KClass::class.asClassName().parameterizedBy(WildcardTypeName.producerOf(rendererWildcard)) ) .addStatement("return %T::class", 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: metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/processor/ContributesRobotProcessor.kt ================================================ package software.amazon.app.platform.metro.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.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.ksp.addOriginatingKSFile import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Inject import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provider import dev.zacsweers.metro.Provides import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.ksp.decapitalize import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE import software.amazon.app.platform.metro.MetroContextAware import software.amazon.app.platform.metro.addMetroOriginAnnotation import software.amazon.app.platform.renderer.metro.RobotKey /** * Generates the necessary code in order to support [ContributesRobot]. * * If you use `@ContributesRobot(AbcScope::class)`, then this processor will generate a graph * interface, which gets contributed to this scope. * * ``` * package app.platform.inject.metro.software.amazon.test * * @ContributesTo(scope = AbcScope::class) * public interface AbcRobotGraph { * @Provide * fun provideAbcRobot(): AbcRobot = AbcRobot() * * @Provides * @IntoMap * @RobotKey(AbcRobot::class) * fun provideAbcRobotIntoMap( * robot: Provider, * ): Robot = robot() * } * ``` */ @OptIn(KspExperimental::class) internal class ContributesRobotProcessor( private val codeGenerator: CodeGenerator, override val logger: KSPLogger, ) : SymbolProcessor, MetroContextAware { private val robotClassName = ClassName("software.amazon.app.platform.robot", "Robot") private val robotFqName = robotClassName.canonicalName private val robotKey = RobotKey::class.asClassName() override fun process(resolver: Resolver): List { resolver .getSymbolsWithAnnotation(ContributesRobot::class) .filterIsInstance() .onEach { checkIsPublic(it) checkHasInjectAnnotation(it) checkNotSingleton(it) checkSuperType(it) checkAppScope(it) } .forEach { generateGraph(it) } return emptyList() } private fun generateGraph(clazz: KSClassDeclaration) { val packageName = "${METRO_LOOKUP_PACKAGE}.${clazz.packageName.asString()}" val graphClassName = ClassName(packageName, "${clazz.innerClassNames()}Graph") val fileSpec = FileSpec.builder(graphClassName) .addType( TypeSpec.interfaceBuilder(graphClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addMetroOriginAnnotation(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) .addAnnotation( AnnotationSpec.builder(robotKey) .addMember("%T::class", clazz.toClassName()) .build() ) .addParameter( name = "robot", type = Provider::class.asClassName().parameterizedBy(clazz.toClassName()), ) .returns(robotClassName) .addStatement("return robot()") .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.isMetroScopeAnnotation() }, 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.isMetroScopeAnnotation() }.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: metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/processor/ContributesScopedProcessor.kt ================================================ package software.amazon.app.platform.metro.processor import com.google.devtools.ksp.KspExperimental import com.google.devtools.ksp.getAllSuperTypes 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.PropertySpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.addOriginatingKSFile import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.IntoSet import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE import software.amazon.app.platform.metro.MetroContextAware import software.amazon.app.platform.metro.addMetroOriginAnnotation /** * Generates the necessary code in order to support [ContributesScoped]. * * ``` * package app.platform.inject.metro.software.amazon.test * * @ContributesTo(scope = AbcScope::class) * public interface TestClassGraph { * * @Binds * val TestClass.bindSuperType: SuperType * * @Binds @IntoSet @ForScope(UserScope::class) * val TestClass.bindScoped: Scoped * } * ``` */ @OptIn(KspExperimental::class) internal class ContributesScopedProcessor( private val codeGenerator: CodeGenerator, override val logger: KSPLogger, ) : SymbolProcessor, MetroContextAware { override fun process(resolver: Resolver): List { resolver .getSymbolsWithAnnotation(ContributesScoped::class) .filterIsInstance() .onEach { checkIsPublic(it) checkHasInjectAnnotation(it) checkImplementsScoped(it) checkSuperType(it) } .forEach { generateGraph(it) } resolver .getSymbolsWithAnnotation(ContributesBinding::class) .filterIsInstance() .forEach { checkDoesNotImplementScoped(it) } return emptyList() } private fun generateGraph(clazz: KSClassDeclaration) { val packageName = "${METRO_LOOKUP_PACKAGE}.${clazz.packageName.asString()}" val graphClassName = ClassName(packageName, "${clazz.innerClassNames()}Graph") val scopeClassName = clazz.scope().type.toClassName() val fileSpec = FileSpec.builder(graphClassName) .addType( TypeSpec.interfaceBuilder(graphClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addMetroOriginAnnotation(clazz) .addAnnotation( AnnotationSpec.builder(ContributesTo::class) .addMember("%T::class", scopeClassName) .build() ) .addProperties( clazz.superTypes .filter { it.resolve().declaration.requireQualifiedName() != scopedFqName } .map { val type = it.resolve() PropertySpec.builder( "bind${type.declaration.innerClassNames()}", type.toClassName(), ) .addAnnotation(Binds::class) .receiver(clazz.toClassName()) .build() } .toList() ) .addProperty( PropertySpec.builder("bind${clazz.innerClassNames()}Scoped", scopedClassName) .addAnnotation(Binds::class) .addAnnotation(IntoSet::class) .addAnnotation( AnnotationSpec.builder(ForScope::class) .addMember("%T::class", scopeClassName) .build() ) .receiver(clazz.toClassName()) .build() ) .build() ) .build() fileSpec.writeTo(codeGenerator, aggregating = false) } private fun checkHasInjectAnnotation(clazz: KSClassDeclaration) { check(clazz.annotations.any { it.isAnnotation(injectFqName) }, clazz) { "${clazz.simpleName.asString()} must be annotated with @Inject when " + "using @ContributesScoped." } } private fun checkImplementsScoped(clazz: KSClassDeclaration) { val extendsScoped = clazz.getAllSuperTypes().any { it.declaration.qualifiedName?.asString() == scopedFqName } check(extendsScoped, clazz) { "In order to use @ContributesScoped, ${clazz.simpleName.asString()} must " + "implement $scopedFqName." } } private fun checkSuperType(clazz: KSClassDeclaration) { val superTypeCount = clazz.superTypes .filter { it.resolve().declaration.requireQualifiedName() != scopedFqName } .count() check(superTypeCount < 2, clazz) { "In order to use @ContributesScoped, ${clazz.simpleName.asString()} is allowed to have only one " + "other super type besides Scoped." } } private fun checkDoesNotImplementScoped(clazz: KSClassDeclaration) { check( clazz.superTypes.none { it.resolve().declaration.requireQualifiedName() == scopedFqName }, clazz, ) { "${clazz.simpleName.asString()} implements Scoped, but uses @ContributesBinding instead " + "of @ContributesScoped. When implementing Scoped the annotation @ContributesScoped " + "must be used instead of @ContributesBinding to bind both super types correctly. It's " + "not necessary to use @ContributesBinding." } } } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/CommonSourceCode.kt ================================================ @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.inject.metro import com.tschuchort.compiletesting.JvmCompilationResult import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi internal val JvmCompilationResult.graphInterface: Class<*> get() = classLoader.loadClass("software.amazon.test.GraphInterface") internal fun Class<*>.newMetroGraph(): T { val companionObject = fields.single().get(null) @Suppress("UNCHECKED_CAST") return classes .single { it.simpleName == "Companion" } .declaredMethods .single { it.name == "create" } .invoke(companionObject) as T } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/Compilation.kt ================================================ @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.inject.metro import assertk.assertThat import com.google.devtools.ksp.processing.SymbolProcessorProvider import com.tschuchort.compiletesting.JvmCompilationResult import com.tschuchort.compiletesting.KotlinCompilation import com.tschuchort.compiletesting.PluginOption import com.tschuchort.compiletesting.SourceFile import com.tschuchort.compiletesting.addPreviousResultToClasspath import com.tschuchort.compiletesting.configureKsp import dev.zacsweers.metro.compiler.MetroCommandLineProcessor import dev.zacsweers.metro.compiler.MetroCompilerPluginRegistrar 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 val metroCommandLineProcessor = MetroCommandLineProcessor() kotlinCompilation.compilerPluginRegistrars = listOf(MetroCompilerPluginRegistrar()) kotlinCompilation.commandLineProcessors = listOf(metroCommandLineProcessor) kotlinCompilation.pluginOptions += PluginOption( pluginId = metroCommandLineProcessor.pluginId, optionName = "unused-graph-inputs-severity", optionValue = "NONE", ) // KSP1 isn't supported with Metro, likely because we run KSP within the kotlinc. That's fine, // we shouldn't bother about KSP1 anymore. kotlinCompilation.configureKsp { symbolProcessorProviders += ServiceLoader.load( SymbolProcessorProvider::class.java, SymbolProcessorProvider::class.java.classLoader, ) // 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_21.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: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/CompilerTestUtil.kt ================================================ package software.amazon.app.platform.inject.metro 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: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/processor/ContributesRendererProcessorTest.kt ================================================ @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.inject.metro.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 dev.zacsweers.metro.ForScope import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provider import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import kotlin.reflect.KClass 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.metro.compile import software.amazon.app.platform.inject.metro.declaredNonSyntheticMethods import software.amazon.app.platform.inject.metro.graphInterface import software.amazon.app.platform.inject.metro.newMetroGraph import software.amazon.app.platform.ksp.inner import software.amazon.app.platform.ksp.isAnnotatedWith import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererScope import software.amazon.app.platform.renderer.metro.RendererKey import software.amazon.test.TestRendererGraph class ContributesRendererProcessorTest { @Test fun `a graph 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 } """, graphInterfaceSource, ) { val generatedGraph = testRenderer.rendererGraph assertThat(generatedGraph.packageName).startsWith(METRO_LOOKUP_PACKAGE) with( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRenderer" } ) { assertThat(parameters).isEmpty() assertThat(returnType).isEqualTo(testRenderer) assertThat(this).isAnnotatedWith(Provides::class) assertThat(getAnnotation(SingleIn::class.java)).isNull() } with( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRendererModel" } ) { assertThat(parameters.single().type).isEqualTo(Provider::class.java) assertThat(returnType).isEqualTo(Renderer::class.java) assertThat(this).isAnnotatedWith(Provides::class) assertThat(this).isAnnotatedWith(IntoMap::class) assertThat(this.getAnnotation(RendererKey::class.java).value).isEqualTo(model) } with( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRendererModelKey" } ) { assertThat(parameters).isEmpty() assertThat(returnType).isEqualTo(KClass::class.java) assertThat(this).isAnnotatedWith(Provides::class) assertThat(this).isAnnotatedWith(IntoMap::class) assertThat(getAnnotation(ForScope::class.java).scope).isEqualTo(RendererScope::class) assertThat(getAnnotation(RendererKey::class.java).value).isEqualTo(model) } assertThat(graphInterface.newMetroGraph().renderers.keys) .containsOnly(model) assertThat(graphInterface.newMetroGraph().modelToRendererMapping.keys) .containsOnly(model) assertThat(graphInterface.newMetroGraph().modelToRendererMapping.values) .containsOnly(testRenderer.kotlin) } } @Test fun `a graph 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 } } """, graphInterfaceSource, ) { val generatedGraph = testRenderer.inner.rendererGraph assertThat(generatedGraph.packageName).startsWith(METRO_LOOKUP_PACKAGE) with( generatedGraph.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( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRendererInnerModel" } ) { assertThat(parameters.single().type).isEqualTo(Provider::class.java) assertThat(returnType).isEqualTo(Renderer::class.java) assertThat(this).isAnnotatedWith(Provides::class) assertThat(this).isAnnotatedWith(IntoMap::class) assertThat(getAnnotation(RendererKey::class.java).value).isEqualTo(model) } assertThat(graphInterface.newMetroGraph().renderers.keys) .containsOnly(model) } } @Test fun `a graph 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 } """, graphInterfaceSource, ) { val generatedGraph = testRenderer.rendererGraph assertThat(generatedGraph.packageName).startsWith(METRO_LOOKUP_PACKAGE) with( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRenderer" } ) { assertThat(parameters).isEmpty() assertThat(returnType).isEqualTo(testRenderer) assertThat(this).isAnnotatedWith(Provides::class) assertThat(getAnnotation(SingleIn::class.java)).isNull() } with( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRendererPresenterModel" } ) { assertThat(parameters.single().type).isEqualTo(Provider::class.java) assertThat(returnType).isEqualTo(Renderer::class.java) assertThat(this).isAnnotatedWith(Provides::class) assertThat(this).isAnnotatedWith(IntoMap::class) } assertThat(graphInterface.newMetroGraph().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 } """, graphInterfaceSource, ) { assertThat(graphInterface.newMetroGraph().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 : OtherRenderer interface OtherRenderer3 : OtherRenderer2 interface OtherRenderer4 : OtherRenderer3 @ContributesRenderer class TestRenderer : OtherRenderer4 { override fun render(model: Model) = Unit } """ ) { assertThat(testRenderer.modelType).isEqualTo(model) } } @Test fun `the model type must be explicit when it cannot be inferred`() { 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 Model1 : BaseModel class Model2 : BaseModel interface OtherRenderer : Renderer @ContributesRenderer class TestRenderer : OtherRenderer { override fun render(model: Model) = Unit } """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Couldn't find BaseModel type for TestRenderer. Consider adding " + "an explicit parameter.Found: software.amazon.test.Model1, software.amazon.test.Model2" ) } } @Test fun `the graph interface contains multiple binding methods for model hierarchies`() { 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 interface Presenter { sealed interface Model : BaseModel { sealed interface Inner : Model { data object Model1 : Inner data object Model2 : Inner } data object Model2 : Model // Note that this class doesn't extend Model. class OtherSubclass } } @ContributesRenderer class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } """, graphInterfaceSource, ) { val generatedGraph = testRenderer.rendererGraph with( generatedGraph.declaredNonSyntheticMethods.single { it.name == "provideSoftwareAmazonTestTestRenderer" } ) { assertThat(parameters).isEmpty() assertThat(returnType).isEqualTo(testRenderer) assertThat(this).isAnnotatedWith(Provides::class) assertThat(getAnnotation(SingleIn::class.java)).isNull() } val bindingMethods = generatedGraph.declaredNonSyntheticMethods.filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModel") && !it.name.endsWith("Key") } assertThat(bindingMethods.map { it.name }) .containsExactlyInAnyOrder( "provideSoftwareAmazonTestTestRendererPresenterModel", "provideSoftwareAmazonTestTestRendererPresenterModelInner", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel1", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel2", "provideSoftwareAmazonTestTestRendererPresenterModelModel2", ) bindingMethods.forEach { assertThat(it.parameters.single().type).isEqualTo(Provider::class.java) assertThat(it.returnType).isEqualTo(Renderer::class.java) assertThat(it).isAnnotatedWith(Provides::class) assertThat(it).isAnnotatedWith(IntoMap::class) assertThat(it).isAnnotatedWith(RendererKey::class) } assertThat(graphInterface.newMetroGraph().renderers.keys) .containsExactlyInAnyOrder( presenter.model.kotlin, presenter.model.inner.kotlin, presenter.model.inner.model1.kotlin, presenter.model.inner.model2.kotlin, presenter.model.model2.kotlin, ) val keyBindingMethods = generatedGraph.declaredNonSyntheticMethods.filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModel") && it.name.endsWith("Key") } assertThat(keyBindingMethods.map { it.name }) .containsExactlyInAnyOrder( "provideSoftwareAmazonTestTestRendererPresenterModelKey", "provideSoftwareAmazonTestTestRendererPresenterModelInnerKey", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel1Key", "provideSoftwareAmazonTestTestRendererPresenterModelInnerModel2Key", "provideSoftwareAmazonTestTestRendererPresenterModelModel2Key", ) keyBindingMethods.forEach { assertThat(it.parameters).isEmpty() assertThat(it.returnType).isEqualTo(KClass::class.java) assertThat(it).isAnnotatedWith(Provides::class) assertThat(it).isAnnotatedWith(IntoMap::class) assertThat(it).isAnnotatedWith(ForScope::class) } assertThat(graphInterface.newMetroGraph().modelToRendererMapping.keys) .containsExactlyInAnyOrder( presenter.model.kotlin, presenter.model.inner.kotlin, presenter.model.inner.model1.kotlin, presenter.model.inner.model2.kotlin, presenter.model.model2.kotlin, ) assertThat( graphInterface.newMetroGraph().modelToRendererMapping.values.distinct() ) .containsOnly(testRenderer.kotlin) } } @Test fun `the binding methods for subtypes are not generated when disabled`() { 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 interface Presenter { sealed interface Model : BaseModel { data object Model1 : Model data object Model2 : Model } } @ContributesRenderer(includeSealedSubtypes = false) class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } """, graphInterfaceSource, ) { val generatedGraph = testRenderer.rendererGraph assertThat( generatedGraph.declaredNonSyntheticMethods .filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModel") && !it.name.endsWith("Key") } .map { it.name } ) .containsOnly("provideSoftwareAmazonTestTestRendererPresenterModel") assertThat( generatedGraph.declaredNonSyntheticMethods .filter { it.name.startsWith("provideSoftwareAmazonTestTestRendererPresenterModelKey") } .map { it.name } ) .containsOnly("provideSoftwareAmazonTestTestRendererPresenterModelKey") assertThat(graphInterface.newMetroGraph().renderers.keys) .containsOnly(presenter.model.kotlin) assertThat(graphInterface.newMetroGraph().modelToRendererMapping.keys) .containsOnly(presenter.model.kotlin) assertThat(graphInterface.newMetroGraph().modelToRendererMapping.values) .containsOnly(testRenderer.kotlin) } } @Test fun `the graph does not contain a binding for the renderer if it is annotated with @Inject`() { compile( """ package software.amazon.test import dev.zacsweers.metro.Inject import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer class Model : BaseModel @ContributesRenderer @Inject class TestRenderer(@Suppress("unused") val string: String) : Renderer { override fun render(model: Model) = Unit } """, graphInterfaceSource, ) { val generatedGraph = testRenderer.rendererGraph assertThat(generatedGraph.packageName).startsWith(METRO_LOOKUP_PACKAGE) assertThat(generatedGraph.declaredNonSyntheticMethods.map { it.name }) .containsOnly( "provideSoftwareAmazonTestTestRendererModel", "provideSoftwareAmazonTestTestRendererModelKey", ) assertThat(graphInterface.newMetroGraph().renderers.keys) .containsOnly(model) } } @Test fun `when using @SingleIn(RendererScope_class) then a warning is printed`() { compile( """ package software.amazon.test import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererScope class Model : BaseModel @Inject @SingleIn(RendererScope::class) @ContributesRenderer class TestRenderer(@Suppress("unused") val string: String) : Renderer { override fun render(model: Model) = Unit } """, graphInterfaceSource, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Source0.kt:15: Renderers should not be singletons in the " + "RendererScope. The RendererFactory will cache the Renderer when " + "necessary. Remove the @SingleIn(RendererScope::class) annotation." ) } } @Test fun `it is redundant to add @Inject for a zero arg constructor`() { 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 import dev.zacsweers.metro.Inject class Model : BaseModel @ContributesRenderer @Inject class TestRenderer : Renderer { override fun render(model: Model) = Unit } """, graphInterfaceSource, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "It's redundant to use @Inject when using @ContributesRenderer " + "for a Renderer with a zero-arg constructor." ) } } @Test fun `it is required to use @Inject for a non-zero arg constructor`() { 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 @ContributesRenderer class TestRenderer(@Suppress("unused") val string: String) : Renderer { override fun render(model: Model) = Unit } """, graphInterfaceSource, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "When using @ContributesRenderer and you need to inject types " + "in the constructor, then it's necessary to add the @Inject annotation." ) } } @Language("kotlin") private val graphInterfaceSource = """ package software.amazon.test import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererScope import kotlin.reflect.KClass import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.createGraph import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Provider import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import software.amazon.test.TestRendererGraph @DependencyGraph(RendererScope::class) @SingleIn(RendererScope::class) interface GraphInterface : TestRendererGraph { @Provides fun provideString(): String = "abc" companion object { fun create(): GraphInterface = createGraph() } } """ private val JvmCompilationResult.testRenderer: Class<*> get() = classLoader.loadClass("software.amazon.test.TestRenderer") private val JvmCompilationResult.model: KClass get() = classLoader.loadClass("software.amazon.test.Model").kotlin private val JvmCompilationResult.presenter: Class<*> get() = classLoader.loadClass("software.amazon.test.Presenter") private val Class<*>.model: Class<*> get() = classes.single { it.simpleName == "Model" } private val Class<*>.model1: Class<*> get() = classes.single { it.simpleName == "Model1" } private val Class<*>.model2: Class<*> get() = classes.single { it.simpleName == "Model2" } private val Class<*>.rendererGraph: Class<*> get() = classLoader.loadClass( "$METRO_LOOKUP_PACKAGE.$packageName." + canonicalName.substringAfter(packageName).substring(1).replace(".", "") + "Graph" ) private val Class<*>.modelType: KClass<*> get() = rendererGraph.declaredNonSyntheticMethods .single { it.name == "provideSoftwareAmazonTestTestRendererModel" } .getAnnotation(RendererKey::class.java) .value } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/processor/ContributesRobotGeneratorTest.kt ================================================ @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.inject.metro.processor import assertk.assertThat import assertk.assertions.contains import assertk.assertions.containsOnly import assertk.assertions.isEmpty 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 dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provider import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn 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.metro.compile import software.amazon.app.platform.inject.metro.declaredNonSyntheticMethods import software.amazon.app.platform.inject.metro.graphInterface import software.amazon.app.platform.inject.metro.newMetroGraph import software.amazon.app.platform.ksp.capitalize import software.amazon.app.platform.ksp.isAnnotatedWith import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE import software.amazon.app.platform.renderer.metro.RobotKey import software.amazon.app.platform.robot.Robot import software.amazon.test.TestRobotGraph class ContributesRobotGeneratorTest { @Test fun `a graph interface is generated without @Inject constructor`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import dev.zacsweers.metro.AppScope @ContributesRobot(AppScope::class) class TestRobot : Robot """, graphInterfaceSource, ) { val robotGraph = testRobot.graph assertThat(robotGraph.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) with(robotGraph.declaredNonSyntheticMethods.single { it.name == "provideTestRobot" }) { assertThat(parameters).isEmpty() assertThat(returnType).isEqualTo(testRobot) assertThat(this).isAnnotatedWith(Provides::class) assertThat(getAnnotation(SingleIn::class.java)).isNull() } with(robotGraph.declaredNonSyntheticMethods.single { it.name == "provideTestRobotIntoMap" }) { assertThat(parameters.single().type).isEqualTo(Provider::class.java) assertThat(returnType).isEqualTo(Robot::class.java) assertThat(this).isAnnotatedWith(Provides::class) assertThat(this).isAnnotatedWith(IntoMap::class) assertThat(getAnnotation(RobotKey::class.java).value.java).isEqualTo(testRobot) } assertThat(graphInterface.newMetroGraph().robots.keys) .containsOnly(testRobot.kotlin) } } @Test fun `a graph interface is generated with @Inject constructor`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject @Inject @ContributesRobot(AppScope::class) class TestRobot : Robot """, graphInterfaceSource, ) { val robotGraph = testRobot.graph assertThat(robotGraph.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) assertThat( robotGraph.declaredNonSyntheticMethods.singleOrNull { it.name == "provideTestRobot" } ) .isNull() with(robotGraph.declaredNonSyntheticMethods.single { it.name == "provideTestRobotIntoMap" }) { assertThat(parameters.single().type).isEqualTo(Provider::class.java) assertThat(returnType).isEqualTo(Robot::class.java) assertThat(this).isAnnotatedWith(Provides::class) assertThat(this).isAnnotatedWith(IntoMap::class) assertThat(getAnnotation(RobotKey::class.java).value.java).isEqualTo(testRobot) } assertThat(graphInterface.newMetroGraph().robots.keys) .containsOnly(testRobot.kotlin) } } @Test fun `a graph interface is generated without direct super type`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import dev.zacsweers.metro.AppScope interface BaseRobot1 : Robot abstract class BaseRobot2 : BaseRobot1 @ContributesRobot(AppScope::class) class TestRobot : BaseRobot2() """ ) { assertThat(testRobot.graph).isNotNull() } } @Test fun `the robot class must be a super type`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import dev.zacsweers.metro.AppScope interface BaseRobot1 abstract class BaseRobot2 : BaseRobot1 @ContributesRobot(AppScope::class) class TestRobot : BaseRobot2() """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "In order to use @ContributesRobot, TestRobot must implement " + "software.amazon.app.platform.robot.Robot." ) } } @Test fun `a Robot must not be a singleton`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @Inject @SingleIn(AppScope::class) @ContributesRobot(AppScope::class) class TestRobot : Robot """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "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 @SingleIn annotation." ) } } @Test fun `only the app scope is supported for now`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.robot.ContributesRobot import software.amazon.app.platform.robot.Robot import software.amazon.lastmile.kotlin.inject.anvil.AppScope @ContributesRobot(String::class) class TestRobot : Robot """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Robots can only be contributed to the AppScope for now. " + "Scope kotlin.String is unsupported." ) } } @Language("kotlin") private val graphInterfaceSource = """ package software.amazon.test import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.createGraph import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.SingleIn @DependencyGraph(AppScope::class) @SingleIn(AppScope::class) interface GraphInterface : TestRobotGraph { companion object { fun create(): GraphInterface = createGraph() } } """ private val JvmCompilationResult.testRobot: Class<*> get() = classLoader.loadClass("software.amazon.test.TestRobot") private val Class<*>.graph: Class<*> get() = classLoader.loadClass( "$METRO_LOOKUP_PACKAGE.$packageName." + canonicalName.substringAfter(packageName).substring(1).split(".").joinToString( separator = "" ) { it.capitalize() } + "Graph" ) } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/processor/ContributesScopedProcessorTest.kt ================================================ @file:OptIn(ExperimentalCompilerApi::class) package software.amazon.app.platform.inject.metro.processor import assertk.assertThat import assertk.assertions.contains import assertk.assertions.hasSize import assertk.assertions.isEqualTo import assertk.assertions.isNotNull import com.tschuchort.compiletesting.JvmCompilationResult import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.jupiter.api.Test import software.amazon.app.platform.inject.metro.compile import software.amazon.app.platform.inject.metro.declaredNonSyntheticMethods import software.amazon.app.platform.inject.metro.graphInterface import software.amazon.app.platform.inject.metro.newMetroGraph import software.amazon.app.platform.ksp.capitalize import software.amazon.app.platform.ksp.inner import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE import software.amazon.app.platform.scope.Scoped class ContributesScopedProcessorTest { @Test fun `a graph interface is generated`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.createGraph import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn interface SuperType @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass : SuperType, Scoped @DependencyGraph(AppScope::class) @SingleIn(AppScope::class) interface GraphInterface { val superTypeInstance: SuperType @ForScope(AppScope::class) val allScoped: Set companion object { fun create(): GraphInterface = createGraph() } } """ ) { val scopedGraph = testClass.graph assertThat(scopedGraph.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) // The annotations for these functions are defined in other kotlinc generated classes. // Instead of relying on reflection, we verify them by running the Metro compiler and // instantiating the Metro graph below. with(scopedGraph.declaredNonSyntheticMethods.single { it.name == "getBindSuperType" }) { assertThat(parameters.single().type).isEqualTo(testClass) assertThat(returnType).isEqualTo(superType) } with(scopedGraph.declaredNonSyntheticMethods.single { it.name == "getBindTestClassScoped" }) { assertThat(parameters.single().type).isEqualTo(testClass) assertThat(returnType).isEqualTo(Scoped::class.java) } val graph = graphInterface.newMetroGraph() @Suppress("UNCHECKED_CAST") val scopedInstances = graphInterface.declaredNonSyntheticMethods .single { it.name == "getAllScoped" } .invoke(graph) as Set assertThat(scopedInstances.single()::class.java).isEqualTo(testClass) @Suppress("UNCHECKED_CAST") assertThat( graphInterface.declaredNonSyntheticMethods .single { it.name == "getSuperTypeInstance" } .invoke(graph)::class .java ) .isEqualTo(testClass) } } @Test fun `a graph interface is generated for an inner class`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn interface SuperType interface TestClass { @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class Inner : SuperType, Scoped } """ ) { val scopedGraph = testClass.inner.graph assertThat(scopedGraph.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) with(scopedGraph.declaredNonSyntheticMethods.single { it.name == "getBindSuperType" }) { assertThat(parameters.single().type).isEqualTo(testClass.inner) assertThat(returnType).isEqualTo(superType) } with( scopedGraph.declaredNonSyntheticMethods.single { it.name == "getBindTestClassInnerScoped" } ) { assertThat(parameters.single().type).isEqualTo(testClass.inner) assertThat(returnType).isEqualTo(Scoped::class.java) } } } @Test fun `a graph interface is generated when only Scoped is implemented`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass : Scoped """ ) { val scopedGraph = testClass.graph assertThat(scopedGraph.getAnnotation(ContributesTo::class.java).scope) .isEqualTo(AppScope::class) assertThat(scopedGraph.declaredNonSyntheticMethods).hasSize(1) with(scopedGraph.declaredNonSyntheticMethods.single { it.name == "getBindTestClassScoped" }) { assertThat(parameters.single().type).isEqualTo(testClass) assertThat(returnType).isEqualTo(Scoped::class.java) } } } @Test fun `it's an error when there is no super type`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "In order to use @ContributesScoped, TestClass must implement software.amazon.app.platform.scope.Scoped." ) } } @Test fun `it's an error when Scoped is not implemented`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass : SuperType """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "In order to use @ContributesScoped, TestClass must implement software.amazon.app.platform.scope.Scoped." ) } } @Test fun `it's an error when there are multiple super types`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn interface SuperType interface SuperType2 @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass : SuperType, SuperType2, Scoped """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "In order to use @ContributesScoped, TestClass is allowed to have only one other super type besides Scoped." ) } } @Test fun `Scoped can be implemented through another type`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn interface SuperType2 : Scoped interface SuperType3 : SuperType2 @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass : SuperType2 """ ) { assertThat(testClass.graph).isNotNull() } } @Test fun `using @ContributesBinding when implementing Scoped is an error`() { compile( """ package software.amazon.test import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn interface SuperType @Inject @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class TestClass : SuperType, Scoped """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "TestClass implements Scoped, but uses @ContributesBinding instead of " + "@ContributesScoped. When implementing Scoped the annotation @ContributesScoped " + "must be used instead of @ContributesBinding to bind both super types correctly. " + "It's not necessary to use @ContributesBinding." ) } } @Test fun `classes using @ContributesScoped can be excluded`() { compile( """ package software.amazon.test import software.amazon.app.platform.inject.metro.ContributesScoped import software.amazon.app.platform.scope.Scoped import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.createGraph import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn interface SuperType @Inject @SingleIn(AppScope::class) @ContributesScoped(AppScope::class) class TestClass : SuperType, Scoped @DependencyGraph(AppScope::class, excludes = [TestClass::class]) @SingleIn(AppScope::class) interface GraphInterface { val superTypeInstance: SuperType @ForScope(AppScope::class) val allScoped: Set companion object { fun create(): GraphInterface = createGraph() } } """, exitCode = COMPILATION_ERROR, ) { assertThat(messages) .contains( "Cannot find an @Inject constructor or @Provides-annotated " + "function/property for: software.amazon.test.SuperType" ) } } private val JvmCompilationResult.testClass: Class<*> get() = classLoader.loadClass("software.amazon.test.TestClass") private val JvmCompilationResult.superType: Class<*> get() = classLoader.loadClass("software.amazon.test.SuperType") private val Class<*>.graph: Class<*> get() = classLoader.loadClass( "$METRO_LOOKUP_PACKAGE.$packageName." + canonicalName.substringAfter(packageName).substring(1).split(".").joinToString( separator = "" ) { it.capitalize() } + "Graph" ) } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/test/TestRendererGraph.kt ================================================ package software.amazon.test import dev.zacsweers.metro.ForScope import dev.zacsweers.metro.Provider import kotlin.reflect.KClass import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererScope interface TestRendererGraph { val renderers: Map, Provider>> @ForScope(RendererScope::class) val modelToRendererMapping: Map, KClass>> } ================================================ FILE: metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/test/TestRobotGraph.kt ================================================ package software.amazon.test import dev.zacsweers.metro.Provider import kotlin.reflect.KClass import software.amazon.app.platform.robot.Robot interface TestRobotGraph { val robots: Map, Provider> } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/build.gradle ================================================ //file:noinspection UnnecessaryQualifiedReference plugins { id 'software.amazon.app.platform.lib.jvm' id 'com.google.devtools.ksp' } appPlatformBuildSrc { enablePublishing true } configurations { annotationsRuntimeClasspath { transitive = false } metroRuntimeClasspath { transitive = false } } dependencies { compileOnly libs.kotlin.compiler.embeddable compileOnly libs.metro.compiler implementation libs.auto.service.annotations ksp libs.auto.service.ksp annotationsRuntimeClasspath project(':di-common:public') annotationsRuntimeClasspath project(':presenter:public') annotationsRuntimeClasspath project(':renderer:public') annotationsRuntimeClasspath project(':metro:public') annotationsRuntimeClasspath project(':robot:public') annotationsRuntimeClasspath project(':scope:public') metroRuntimeClasspath libs.metro.runtime testImplementation project(':di-common:public') testImplementation project(':presenter:public') testImplementation project(':renderer:public') testImplementation project(':metro:public') testImplementation project(':robot:public') testImplementation project(':scope:public') testImplementation libs.kotlin.compiler testImplementation libs.kotlin.compiler.internal.test.framework testImplementation libs.kotlin.test.junit5 testImplementation libs.metro.compiler testRuntimeOnly libs.kotlin.test testRuntimeOnly libs.kotlin.annotations.jvm testRuntimeOnly libs.kotlin.reflect testRuntimeOnly libs.kotlin.script.runtime } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { compilerOptions { freeCompilerArgs.add('-Xcontext-parameters') optIn.add('org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi') } } def jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 configurations .matching { it.name in ['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath'] } .configureEach { attributes.attribute( org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, jvmTarget.target.toInteger(), ) } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile).configureEach { compilerOptions { it.jvmTarget.set(jvmTarget) } } tasks.withType(JavaCompile).configureEach { sourceCompatibility = jvmTarget.target targetCompatibility = jvmTarget.target javaCompiler = javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(jvmTarget.target) } } def annotationsClasspathFiles = configurations.annotationsRuntimeClasspath def metroRuntimeClasspathFiles = configurations.metroRuntimeClasspath def testClasspathFiles = configurations.testRuntimeClasspath def testSupportClasspathFiles = sourceSets.test.output.classesDirs tasks.withType(Test).configureEach { testTask -> useJUnitPlatform() workingDir = rootDir maxHeapSize = '2g' javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(jvmTarget.target) } systemProperty('idea.ignore.disabled.plugins', 'true') systemProperty('idea.home.path', rootDir) if (project.hasProperty('updateTestData')) { systemProperty('kotlin.test.update.test.data', 'true') } doFirst { def annotationsClasspath = annotationsClasspathFiles.asPath def metroRuntimeClasspath = metroRuntimeClasspathFiles.asPath def testSupportClasspath = testSupportClasspathFiles.asPath def classpathFiles = testClasspathFiles.files systemProperty( 'annotationsRuntime.classpath', annotationsClasspath, ) systemProperty('metroRuntime.classpath', metroRuntimeClasspath) systemProperty('testSupport.classpath', testSupportClasspath) def setLib = { String propName, String jarName -> def prefix = "${jarName}-" def path = classpathFiles.find { file -> file.name.startsWith(prefix) && file.name.endsWith('.jar') && Character.isDigit(file.name.substring(prefix.length()).charAt(0)) }?.absolutePath if (path != null) { systemProperty(propName, path) } } setLib('org.jetbrains.kotlin.test.kotlin-stdlib', 'kotlin-stdlib') setLib('org.jetbrains.kotlin.test.kotlin-stdlib-jdk8', 'kotlin-stdlib-jdk8') setLib('org.jetbrains.kotlin.test.kotlin-reflect', 'kotlin-reflect') setLib('org.jetbrains.kotlin.test.kotlin-test', 'kotlin-test') setLib('org.jetbrains.kotlin.test.kotlin-script-runtime', 'kotlin-script-runtime') setLib('org.jetbrains.kotlin.test.kotlin-annotations-jvm', 'kotlin-annotations-jvm') } } tasks.register('generateTests', JavaExec) { inputs .dir(layout.projectDirectory.dir('src/test/resources')) .withPropertyName('testData') .withPathSensitivity(PathSensitivity.RELATIVE) outputs .dir(layout.projectDirectory.dir('src/test/java')) .withPropertyName('generatedTests') classpath = sourceSets.test.runtimeClasspath mainClass = 'software.amazon.app.platform.metro.compiler.GenerateTestsKt' workingDir = rootDir minHeapSize = '128m' maxHeapSize = '1g' jvmArgs('-Xss1m') } // We don't need the apiCheck in this module. tasks.named('apiCheck').configure { it.enabled = false } tasks.named('apiDump').configure { it.enabled = false } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/AppPlatformMetroExtensionsPluginComponentRegistrar.kt ================================================ package software.amazon.app.platform.metro.compiler import com.google.auto.service.AutoService import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter import software.amazon.app.platform.metro.compiler.renderer.ContributesRendererIrExtension import software.amazon.app.platform.metro.compiler.robot.ContributesRobotIrExtension @AutoService(CompilerPluginRegistrar::class) public class AppPlatformMetroExtensionsPluginComponentRegistrar : CompilerPluginRegistrar() { override val pluginId: String = "software.amazon.app.platform.metro.compiler" override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension(AppPlatformMetroExtensionsPluginRegistrar()) IrGenerationExtension.registerExtension(ContributesRendererIrExtension()) IrGenerationExtension.registerExtension(ContributesRobotIrExtension()) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/AppPlatformMetroExtensionsPluginRegistrar.kt ================================================ package software.amazon.app.platform.metro.compiler import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar import software.amazon.app.platform.metro.compiler.fir.AppPlatformMetroExtensionsFirCheckers public class AppPlatformMetroExtensionsPluginRegistrar : FirExtensionRegistrar() { override fun ExtensionRegistrarContext.configurePlugin() { +::AppPlatformMetroExtensionsFirCheckers } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/ClassIds.kt ================================================ package software.amazon.app.platform.metro.compiler import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name internal object ClassIds { val APP_SCOPE = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("AppScope")) val BASE_MODEL = ClassId(FqName("software.amazon.app.platform.presenter"), Name.identifier("BaseModel")) val BINDS = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("Binds")) val CONTRIBUTES_RENDERER = ClassId(FqName("software.amazon.app.platform.inject"), Name.identifier("ContributesRenderer")) val CONTRIBUTES_ROBOT = ClassId( FqName("software.amazon.app.platform.inject.robot"), Name.identifier("ContributesRobot"), ) val CONTRIBUTES_SCOPED = ClassId( FqName("software.amazon.app.platform.inject.metro"), Name.identifier("ContributesScoped"), ) val CONTRIBUTES_BINDING = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("ContributesBinding")) val CONTRIBUTES_TO = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("ContributesTo")) val DEPENDENCY_GRAPH = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("DependencyGraph")) val FOR_SCOPE = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("ForScope")) val INJECT = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("Inject")) val INTO_MAP = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("IntoMap")) val INTO_SET = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("IntoSet")) val ORIGIN = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("Origin")) val PROVIDER = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("Provider")) val PROVIDES = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("Provides")) val RENDERER = ClassId(FqName("software.amazon.app.platform.renderer"), Name.identifier("Renderer")) val RENDERER_KEY = ClassId(FqName("software.amazon.app.platform.renderer.metro"), Name.identifier("RendererKey")) val RENDERER_SCOPE = ClassId(FqName("software.amazon.app.platform.renderer"), Name.identifier("RendererScope")) val ROBOT = ClassId(FqName("software.amazon.app.platform.robot"), Name.identifier("Robot")) val ROBOT_KEY = ClassId(FqName("software.amazon.app.platform.renderer.metro"), Name.identifier("RobotKey")) val ROBOT_FQ_NAMES: Set = setOf(ROBOT) val SINGLE_IN = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("SingleIn")) val SCOPED = ClassId(FqName("software.amazon.app.platform.scope"), Name.identifier("Scoped")) val SCOPE = ClassId(FqName("dev.zacsweers.metro"), Name.identifier("Scope")) val UNIT = ClassId(FqName("kotlin"), Name.identifier("Unit")) } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/Keys.kt ================================================ package software.amazon.app.platform.metro.compiler import org.jetbrains.kotlin.GeneratedDeclarationKey internal object Keys { data object ContributesRendererGeneratorKey : GeneratedDeclarationKey() { override fun toString(): String = "ContributesRendererGenerator" } data object ContributesRobotGeneratorKey : GeneratedDeclarationKey() { override fun toString(): String = "ContributesRobotGenerator" } data object ContributesScopedGeneratorKey : GeneratedDeclarationKey() { override fun toString(): String = "ContributesScopedGenerator" } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/fir/AppPlatformMetroExtensionsDiagnostics.kt ================================================ package software.amazon.app.platform.metro.compiler.fir import org.jetbrains.kotlin.diagnostics.KtDiagnosticFactoryToRendererMap import org.jetbrains.kotlin.diagnostics.KtDiagnosticsContainer import org.jetbrains.kotlin.diagnostics.SourceElementPositioningStrategies import org.jetbrains.kotlin.diagnostics.error1 import org.jetbrains.kotlin.diagnostics.rendering.BaseDiagnosticRendererFactory import org.jetbrains.kotlin.diagnostics.rendering.CommonRenderers import org.jetbrains.kotlin.psi.KtElement internal object AppPlatformMetroExtensionsDiagnostics : KtDiagnosticsContainer() { val CONTRIBUTES_RENDERER_ERROR by error1(SourceElementPositioningStrategies.NAME_IDENTIFIER) val CONTRIBUTES_ROBOT_ERROR by error1(SourceElementPositioningStrategies.NAME_IDENTIFIER) val CONTRIBUTES_SCOPED_ERROR by error1(SourceElementPositioningStrategies.NAME_IDENTIFIER) override fun getRendererFactory(): BaseDiagnosticRendererFactory { return AppPlatformMetroExtensionsErrorMessages } } private object AppPlatformMetroExtensionsErrorMessages : BaseDiagnosticRendererFactory() { override val MAP by KtDiagnosticFactoryToRendererMap("AppPlatformMetroExtensions") { map -> map.put( AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_RENDERER_ERROR, "{0}", CommonRenderers.STRING, ) map.put( AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_ROBOT_ERROR, "{0}", CommonRenderers.STRING, ) map.put( AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_SCOPED_ERROR, "{0}", CommonRenderers.STRING, ) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/fir/AppPlatformMetroExtensionsFirCheckers.kt ================================================ package software.amazon.app.platform.metro.compiler.fir import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.analysis.checkers.declaration.DeclarationCheckers import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirClassChecker import org.jetbrains.kotlin.fir.analysis.extensions.FirAdditionalCheckersExtension import software.amazon.app.platform.metro.compiler.renderer.ContributesRendererChecker import software.amazon.app.platform.metro.compiler.robot.ContributesRobotChecker import software.amazon.app.platform.metro.compiler.scoped.ContributesScopedChecker internal class AppPlatformMetroExtensionsFirCheckers(session: FirSession) : FirAdditionalCheckersExtension(session) { override val declarationCheckers: DeclarationCheckers = object : DeclarationCheckers() { override val classCheckers: Set = setOf(ContributesRendererChecker, ContributesRobotChecker, ContributesScopedChecker) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/fir/FirHelpers.kt ================================================ package software.amazon.app.platform.metro.compiler.fir import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirDeclaration import org.jetbrains.kotlin.fir.declarations.FirDeclarationOrigin import org.jetbrains.kotlin.fir.declarations.FirFile import org.jetbrains.kotlin.fir.declarations.FirRegularClass import org.jetbrains.kotlin.fir.declarations.FirResolvePhase import org.jetbrains.kotlin.fir.declarations.builder.buildValueParameter import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe import org.jetbrains.kotlin.fir.expressions.FirAnnotation import org.jetbrains.kotlin.fir.expressions.FirAnnotationCall import org.jetbrains.kotlin.fir.expressions.FirAnnotationResolvePhase import org.jetbrains.kotlin.fir.expressions.FirExpression import org.jetbrains.kotlin.fir.expressions.FirGetClassCall import org.jetbrains.kotlin.fir.expressions.FirNamedArgumentExpression import org.jetbrains.kotlin.fir.expressions.FirPropertyAccessExpression import org.jetbrains.kotlin.fir.expressions.FirResolvedQualifier import org.jetbrains.kotlin.fir.expressions.buildResolvedArgumentList import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationArgumentMapping import org.jetbrains.kotlin.fir.expressions.builder.buildGetClassCall import org.jetbrains.kotlin.fir.expressions.builder.buildResolvedQualifier import org.jetbrains.kotlin.fir.moduleData import org.jetbrains.kotlin.fir.references.FirResolvedNamedReference import org.jetbrains.kotlin.fir.references.builder.buildResolvedNamedReference import org.jetbrains.kotlin.fir.resolve.fullyExpandedType import org.jetbrains.kotlin.fir.resolve.providers.firProvider import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.FirBasedSymbol import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.ConeClassLikeLookupTagImpl import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol import org.jetbrains.kotlin.fir.toFirResolvedTypeRef import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.impl.ConeClassLikeTypeImpl import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name internal fun hasAnnotation( classSymbol: FirClassSymbol<*>, annotationClassId: ClassId, session: FirSession, ): Boolean { return classSymbol.resolvedCompilerAnnotationsWithClassIds.any { it.toAnnotationClassIdSafe(session) == annotationClassId } } internal fun findAnnotation( classSymbol: FirClassSymbol<*>, annotationClassId: ClassId, session: FirSession, ): FirAnnotation? { return classSymbol.resolvedAnnotationsWithArguments.firstOrNull { annotation -> annotation.toAnnotationClassIdSafe(session) == annotationClassId } } @OptIn(DirectDeclarationsAccess::class, SymbolInternals::class) internal fun buildAnnotationCallWithArgument( classId: ClassId, argName: Name, argument: FirExpression, containingSymbol: FirBasedSymbol<*>, session: FirSession, ): FirAnnotationCall { val annotationType = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(classId), emptyArray(), isMarkedNullable = false, ) val annotationClassSymbol = session.symbolProvider.getClassLikeSymbolByClassId(classId) ?: error("Annotation class $classId not found on the classpath") val constructorSymbol = (annotationClassSymbol as? FirClassSymbol<*>) ?.declarationSymbols ?.filterIsInstance() ?.firstOrNull() ?: error("No constructor found for annotation class $classId") val argumentParameter = constructorSymbol.fir.valueParameters.first { it.name == argName } return org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationCall { annotationTypeRef = annotationType.toFirResolvedTypeRef() argumentMapping = buildAnnotationArgumentMapping { mapping[argName] = argument } argumentList = buildResolvedArgumentList( original = null, mapping = linkedMapOf(argument to argumentParameter), ) calleeReference = buildResolvedNamedReference { name = classId.shortClassName resolvedSymbol = constructorSymbol } containingDeclarationSymbol = containingSymbol annotationResolvePhase = FirAnnotationResolvePhase.Types } } @OptIn(DirectDeclarationsAccess::class) internal fun buildSimpleAnnotationCall( classId: ClassId, containingSymbol: FirBasedSymbol<*>, session: FirSession, ): FirAnnotationCall { val annotationType = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(classId), emptyArray(), isMarkedNullable = false, ) val annotationClassSymbol = session.symbolProvider.getClassLikeSymbolByClassId(classId) ?: error("Annotation class $classId not found on the classpath") val constructorSymbol = (annotationClassSymbol as? FirClassSymbol<*>) ?.declarationSymbols ?.filterIsInstance() ?.firstOrNull() ?: error("No constructor found for annotation class $classId") return org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationCall { annotationTypeRef = annotationType.toFirResolvedTypeRef() argumentMapping = buildAnnotationArgumentMapping() calleeReference = buildResolvedNamedReference { name = classId.shortClassName resolvedSymbol = constructorSymbol } containingDeclarationSymbol = containingSymbol annotationResolvePhase = FirAnnotationResolvePhase.Types } } internal fun extractScopeArgument( classSymbol: FirClassSymbol<*>, annotationClassId: ClassId, session: FirSession, ): FirExpression? { val annotation = findAnnotation(classSymbol, annotationClassId, session) ?: return null val annotationCall = annotation as? FirAnnotationCall ?: return null val firstArgument = annotationCall.argumentList.arguments.firstOrNull() ?: return null return if (firstArgument is FirNamedArgumentExpression) { firstArgument.expression } else { firstArgument } } internal fun extractScopeClassId( classSymbol: FirRegularClassSymbol, annotationClassId: ClassId, session: FirSession, ): ClassId? { val annotation = findAnnotation(classSymbol, annotationClassId, session) ?: return null val annotationCall = annotation as? FirAnnotationCall ?: return null val rawScopeExpression = annotationCall.argumentMapping.mapping[Name.identifier("scope")] ?: annotationCall.argumentList.arguments.firstOrNull() ?: return null return resolveClassIdArgument(rawScopeExpression, classSymbol, session) } internal fun unwrapArgumentExpression(expression: FirExpression): FirExpression { return if (expression is FirNamedArgumentExpression) expression.expression else expression } internal data class ResolvedClassReference( val classId: ClassId, val classSymbol: FirRegularClassSymbol?, ) internal fun resolveClassIdArgument( rawExpression: FirExpression, classSymbol: FirRegularClassSymbol, session: FirSession, ): ClassId? { return resolveClassReferenceArgument(rawExpression, classSymbol, session)?.classId } internal fun resolveClassReferenceArgument( rawExpression: FirExpression, classSymbol: FirRegularClassSymbol, session: FirSession, ): ResolvedClassReference? { val expression = unwrapArgumentExpression(rawExpression) val getClassCall = expression as? FirGetClassCall ?: return null val innerArgument = getClassCall.argumentList.arguments.firstOrNull() ?: return null return when (innerArgument) { is FirResolvedQualifier -> innerArgument.classId?.let { classId -> ResolvedClassReference( classId = classId, classSymbol = (innerArgument.symbol as? FirRegularClassSymbol) ?: (session.symbolProvider.getClassLikeSymbolByClassId(classId) as? FirRegularClassSymbol) ?: findClassLikeSymbolInContainingFile(classSymbol, classId, session) ?: findClassLikeSymbolInPackageFiles( classSymbol.classId.packageFqName, classId, session, ), ) } is FirPropertyAccessExpression -> { val reference = innerArgument.calleeReference if ( reference is FirResolvedNamedReference && reference.resolvedSymbol is FirRegularClassSymbol ) { val resolvedSymbol = reference.resolvedSymbol as FirRegularClassSymbol ResolvedClassReference(resolvedSymbol.classId, resolvedSymbol) } else { val name = reference.name val file = session.firProvider.getFirClassifierContainerFileIfAny(classSymbol) val samePackageClassId = if (name.asString() == "Unit") { ClassId(FqName("kotlin"), name) } else { ClassId(classSymbol.classId.packageFqName, name) } val explicitImportClassIds = file ?.imports ?.filter { !it.isAllUnder } ?.mapNotNull { import -> val importedFqName = import.importedFqName ?: return@mapNotNull null ClassId.topLevel(importedFqName).takeIf { importedFqName.shortName() == name } } .orEmpty() val allUnderImportClassIds = file ?.imports ?.filter { it.isAllUnder } ?.mapNotNull { import -> val importedFqName = import.importedFqName ?: return@mapNotNull null ClassId(importedFqName, name) } .orEmpty() val classId = sequenceOf(samePackageClassId) .plus(explicitImportClassIds.asSequence()) .plus(allUnderImportClassIds.asSequence()) .firstOrNull { candidateClassId -> session.symbolProvider.getClassLikeSymbolByClassId(candidateClassId) != null || findClassLikeSymbolInContainingFile(classSymbol, candidateClassId, session) != null || findClassLikeSymbolInPackageFiles( classSymbol.classId.packageFqName, candidateClassId, session, ) != null } ?: samePackageClassId ResolvedClassReference( classId, (session.symbolProvider.getClassLikeSymbolByClassId(classId) as? FirRegularClassSymbol) ?: findClassLikeSymbolInContainingFile(classSymbol, classId, session) ?: findClassLikeSymbolInPackageFiles( classSymbol.classId.packageFqName, classId, session, ), ) } } else -> null } } internal fun buildClassExpression( classSymbol: FirClassSymbol<*>, session: FirSession, ): FirExpression { val classId = classSymbol.classId val classType = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(classId), emptyArray(), isMarkedNullable = false, ) val kClassClassId = ClassId(FqName("kotlin.reflect"), Name.identifier("KClass")) val kClassType = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(kClassClassId), arrayOf(classType), isMarkedNullable = false, ) return buildGetClassCall { coneTypeOrNull = kClassType val qualifier = buildResolvedQualifier { packageFqName = classId.packageFqName relativeClassFqName = classId.relativeClassName coneTypeOrNull = classType symbol = classSymbol resolvedToCompanionObject = false isFullyQualified = true } argumentList = buildResolvedArgumentList( original = null, mapping = linkedMapOf( qualifier to buildSyntheticClassLiteralParameter(classType, classSymbol, session) ), ) } } internal fun buildClassExpression( classId: ClassId, session: FirSession, ownerSymbol: FirRegularClassSymbol? = null, ): FirExpression { val classType = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(classId), emptyArray(), isMarkedNullable = false, ) val kClassClassId = ClassId(FqName("kotlin.reflect"), Name.identifier("KClass")) val kClassType = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(kClassClassId), arrayOf(classType), isMarkedNullable = false, ) val classSymbol = session.symbolProvider.getClassLikeSymbolByClassId(classId) ?: ownerSymbol?.let { findClassLikeSymbolInContainingFile(it, classId, session) } ?: ownerSymbol?.let { findClassLikeSymbolInPackageFiles(it.classId.packageFqName, classId, session) } return buildGetClassCall { coneTypeOrNull = kClassType val qualifier = buildResolvedQualifier { packageFqName = classId.packageFqName relativeClassFqName = classId.relativeClassName coneTypeOrNull = classType symbol = classSymbol resolvedToCompanionObject = false isFullyQualified = classSymbol != null } argumentList = buildResolvedArgumentList( original = null, mapping = linkedMapOf( qualifier to buildSyntheticClassLiteralParameter( classType = classType, containingSymbol = classSymbol ?: ownerSymbol, session = session, ) ), ) } } private fun buildSyntheticClassLiteralParameter( classType: ConeClassLikeTypeImpl, containingSymbol: FirBasedSymbol<*>?, session: FirSession, ) = buildValueParameter { moduleData = session.moduleData resolvePhase = FirResolvePhase.BODY_RESOLVE origin = FirDeclarationOrigin.Synthetic.PluginFile returnTypeRef = classType.toFirResolvedTypeRef() name = Name.identifier("value") symbol = FirValueParameterSymbol() containingDeclarationSymbol = containingSymbol ?: error("Unable to determine containing symbol for generated class literal") } @OptIn(DirectDeclarationsAccess::class) internal fun findClassLikeSymbolInContainingFile( ownerSymbol: FirRegularClassSymbol, classId: ClassId, session: FirSession, ): FirRegularClassSymbol? { val file = session.firProvider.getFirClassifierContainerFileIfAny(ownerSymbol) ?: return null return findClassLikeSymbolInFile(file, classId) } @OptIn(DirectDeclarationsAccess::class) internal fun findClassLikeSymbolInPackageFiles( packageFqName: FqName, classId: ClassId, session: FirSession, ): FirRegularClassSymbol? { return session.firProvider.getFirFilesByPackage(packageFqName).firstNotNullOfOrNull { file -> findClassLikeSymbolInFile(file, classId) } } @OptIn(DirectDeclarationsAccess::class) private fun findClassLikeSymbolInFile(file: FirFile, classId: ClassId): FirRegularClassSymbol? { return file.declarations.firstNotNullOfOrNull { declaration -> findClassLikeSymbolInDeclaration(declaration, classId) } } @OptIn(DirectDeclarationsAccess::class) private fun findClassLikeSymbolInDeclaration( declaration: FirDeclaration, classId: ClassId, ): FirRegularClassSymbol? { val regularClass = declaration as? FirRegularClass if (regularClass?.symbol?.classId == classId) { return regularClass.symbol } if (regularClass != null) { return regularClass.declarations.firstNotNullOfOrNull { nestedDeclaration -> findClassLikeSymbolInDeclaration(nestedDeclaration, classId) } } return null } internal fun hasTransitiveSupertype( type: ConeKotlinType, session: FirSession, targetIds: Collection, visited: MutableSet = mutableSetOf(), ): Boolean { val classSymbol = type.toRegularClassSymbol(session) ?: return false val classId = classSymbol.classId if (!visited.add(classId)) return false if (classId in targetIds) return true return classSymbol.resolvedSuperTypeRefs.any { superTypeRef -> val superConeType = superTypeRef.coneType.fullyExpandedType(session) hasTransitiveSupertype(superConeType, session, targetIds, visited) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/fir/TypeResolution.kt ================================================ package software.amazon.app.platform.metro.compiler.fir import org.jetbrains.kotlin.fir.FirModuleData import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.FirFile import org.jetbrains.kotlin.fir.moduleData import org.jetbrains.kotlin.fir.resolve.ScopeSession import org.jetbrains.kotlin.fir.resolve.SupertypeSupplier import org.jetbrains.kotlin.fir.resolve.TypeResolutionConfiguration import org.jetbrains.kotlin.fir.resolve.fullyExpandedType import org.jetbrains.kotlin.fir.resolve.providers.firProvider import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.resolve.substitution.substitutorByMap import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol import org.jetbrains.kotlin.fir.resolve.typeResolver import org.jetbrains.kotlin.fir.scopes.createImportingScopes import org.jetbrains.kotlin.fir.scopes.getSingleClassifier import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.ConeClassLikeLookupTagImpl import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.ConeKotlinTypeConflictingProjection import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjection import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjectionIn import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjectionOut import org.jetbrains.kotlin.fir.types.ConeStarProjection import org.jetbrains.kotlin.fir.types.FirResolvedTypeRef import org.jetbrains.kotlin.fir.types.FirStarProjection import org.jetbrains.kotlin.fir.types.FirTypeProjection import org.jetbrains.kotlin.fir.types.FirTypeProjectionWithVariance import org.jetbrains.kotlin.fir.types.FirTypeRef import org.jetbrains.kotlin.fir.types.FirUserTypeRef import org.jetbrains.kotlin.fir.types.classId import org.jetbrains.kotlin.fir.types.coneTypeOrNull import org.jetbrains.kotlin.fir.types.impl.ConeClassLikeTypeImpl import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.name.StandardClassIds import org.jetbrains.kotlin.types.Variance @OptIn(SymbolInternals::class) internal fun resolveDeclaredSuperTypes( classSymbol: FirRegularClassSymbol, session: FirSession, actualType: ConeKotlinType? = null, ): List { val resolutionSession = classSymbol.fir.moduleData.session val substitution = actualType ?.let { actual -> classSymbol.typeParameterSymbols.zip(actual.typeArguments).mapNotNull { (parameter, arg) -> (arg as? ConeKotlinTypeProjection)?.type?.let { parameter to it } } } .orEmpty() val substitutor = substitution.takeIf { it.isNotEmpty() }?.let { substitutorByMap(it.toMap(), session) } return classSymbol.fir.superTypeRefs.mapNotNull { superTypeRef -> val resolvedType = resolveSuperTypeRef(superTypeRef, classSymbol, resolutionSession) ?: return@mapNotNull null val substitutedType = substitutor?.substituteOrNull(resolvedType) ?: resolvedType val expandedType = substitutedType.fullyExpandedType(session) expandedType.takeUnless { it.toRegularClassSymbol(session)?.classId == StandardClassIds.Any } } } internal fun findContainingFile(classSymbol: FirClassLikeSymbol<*>, session: FirSession): FirFile? { return allSessions(session).firstNotNullOfOrNull { candidate -> candidate.firProvider.getFirClassifierContainerFileIfAny(classSymbol) } } internal fun allSessions(session: FirSession): List { val visitedModules = linkedSetOf() val visited = linkedSetOf() fun visit(moduleData: FirModuleData) { visited.add(moduleData.session) if (!visitedModules.add(moduleData)) return moduleData.dependencies.forEach(::visit) moduleData.friendDependencies.forEach(::visit) moduleData.dependsOnDependencies.forEach(::visit) } visit(session.moduleData) return visited.toList() } private fun resolveSuperTypeRef( typeRef: FirTypeRef, owner: FirRegularClassSymbol, session: FirSession, ): ConeKotlinType? { return when (typeRef) { is FirResolvedTypeRef -> typeRef.coneType is FirUserTypeRef -> resolveUserType(typeRef, owner, session) else -> typeRef.coneTypeOrNull }?.fullyExpandedType(session) } private fun resolveUserType( typeRef: FirUserTypeRef, owner: FirRegularClassSymbol, session: FirSession, ): ConeKotlinType? { val manualType = resolveUserTypeManually(typeRef, owner, session) val file = findContainingFile(owner, session) ?: return typeRef.coneTypeOrNull ?: manualType val scopes = createImportingScopes(file, session, ScopeSession()) val configuration = TypeResolutionConfiguration(scopes, emptyList(), useSiteFile = file) val resolvedType = runCatching { session.typeResolver .resolveType( typeRef = typeRef, configuration = configuration, areBareTypesAllowed = true, isOperandOfIsOperator = false, resolveDeprecations = false, supertypeSupplier = SupertypeSupplier.Default, expandTypeAliases = false, ) .type } .getOrElse { return manualType ?: throw it } if (resolvedType.classId?.shortClassName?.asString() != "") { return resolvedType } return manualType ?: resolvedType } private fun resolveUserTypeManually( typeRef: FirUserTypeRef, owner: FirRegularClassSymbol, session: FirSession, ): ConeKotlinType? { val fallbackClassId = resolveUserTypeClassId(typeRef, owner, session) ?: return null val typeArguments = typeRef.qualifier.lastOrNull()?.typeArgumentList?.typeArguments?.map { argument -> resolveTypeProjection(argument, owner, session) } return ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(fallbackClassId), typeArguments.orEmpty().toTypedArray(), isMarkedNullable = typeRef.isMarkedNullable, ) } private fun resolveTypeProjection( projection: FirTypeProjection, owner: FirRegularClassSymbol, session: FirSession, ) = when (projection) { is FirStarProjection -> ConeStarProjection is FirTypeProjectionWithVariance -> { val resolvedType = resolveSuperTypeRef(projection.typeRef, owner, session) ?: projection.typeRef.coneTypeOrNull when { resolvedType == null -> ConeStarProjection projection.variance == Variance.IN_VARIANCE -> ConeKotlinTypeProjectionIn(resolvedType) projection.variance == Variance.OUT_VARIANCE -> ConeKotlinTypeProjectionOut(resolvedType) else -> ConeKotlinTypeConflictingProjection(resolvedType) } } else -> ConeStarProjection } private fun resolveUserTypeClassId( typeRef: FirUserTypeRef, owner: FirRegularClassSymbol, session: FirSession, ): ClassId? { val qualifierNames = typeRef.qualifier.map { it.name } if (qualifierNames.isEmpty()) return null val importedClassId = findContainingFile(owner, session)?.let { file -> val scopes = createImportingScopes(file, session, ScopeSession()) scopes.firstNotNullOfOrNull { scope -> val importedSymbol = scope.getSingleClassifier(qualifierNames.first()) as? FirClassLikeSymbol<*> ?: return@firstNotNullOfOrNull null qualifierNames.drop(1).fold(importedSymbol.classId) { classId, name -> classId.createNestedClassId(name) } } } if (importedClassId != null) return importedClassId return userTypeClassIdCandidates(owner, qualifierNames).firstOrNull { classId -> session.symbolProvider.getClassLikeSymbolByClassId(classId) != null || findClassLikeSymbolInContainingFile(owner, classId, session) != null || findClassLikeSymbolInPackageFiles(owner.classId.packageFqName, classId, session) != null } } private fun userTypeClassIdCandidates( owner: FirRegularClassSymbol, qualifierNames: List, ): Sequence { fun nestedClassId(base: ClassId, nestedNames: List): ClassId { return nestedNames.fold(base) { classId, name -> classId.createNestedClassId(name) } } val topLevelCandidate = nestedClassId( ClassId.topLevel(owner.classId.packageFqName.child(qualifierNames.first())), qualifierNames.drop(1), ) return sequence { yield(topLevelCandidate) var parentClassId = owner.classId.parentClassId while (parentClassId != null) { yield(nestedClassId(parentClassId, qualifierNames)) parentClassId = parentClassId.parentClassId } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/renderer/ContributesRendererChecker.kt ================================================ package software.amazon.app.platform.metro.compiler.renderer import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.diagnostics.DiagnosticReporter import org.jetbrains.kotlin.diagnostics.reportOn import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirClassChecker import org.jetbrains.kotlin.fir.declarations.FirClass import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.fir.AppPlatformMetroExtensionsDiagnostics import software.amazon.app.platform.metro.compiler.fir.hasAnnotation internal object ContributesRendererChecker : FirClassChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirClass) { declaration.source ?: return val session = context.session val annotation = declaration.annotations.firstOrNull { candidate -> candidate.toAnnotationClassId(session) == ContributesRendererIds.CONTRIBUTES_RENDERER_CLASS_ID } ?: return val classSymbol = declaration.symbol as? FirRegularClassSymbol ?: return if (declaration.classKind != ClassKind.CLASS) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_RENDERER_ERROR, "@ContributesRenderer can only be applied to classes, not " + "${declaration.classKind.name.lowercase().replace('_', ' ')}s.", ) return } when (val modelTypeResolution = resolveRendererModelType(classSymbol, session)) { is RendererModelTypeResolution.Error -> { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_RENDERER_ERROR, modelTypeResolution.message, ) } is RendererModelTypeResolution.Success -> Unit } if (isSingleInRendererScope(classSymbol, session)) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_RENDERER_ERROR, "Renderers should not be singletons in the RendererScope. The RendererFactory will " + "cache the Renderer when necessary. Remove the @SingleIn(RendererScope::class) " + "annotation.", ) } val parameterCount = constructorParameterCount(classSymbol) if (hasAnnotation(classSymbol, ClassIds.INJECT, session)) { if (parameterCount == 0) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_RENDERER_ERROR, "It's redundant to use @Inject when using @ContributesRenderer for a Renderer with " + "a zero-arg constructor.", ) } } else if (parameterCount > 0) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_RENDERER_ERROR, "When using @ContributesRenderer and you need to inject types in the constructor, " + "then it's necessary to add the @Inject annotation.", ) } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/renderer/ContributesRendererFir.kt ================================================ package software.amazon.app.platform.metro.compiler.renderer import com.google.auto.service.AutoService import dev.zacsweers.metro.compiler.MetroOptions import dev.zacsweers.metro.compiler.api.fir.MetroFirDeclarationGenerationExtension import dev.zacsweers.metro.compiler.compat.CompatContext import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.descriptors.Modality import org.jetbrains.kotlin.descriptors.Visibilities import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.FirFunction import org.jetbrains.kotlin.fir.declarations.FirResolvePhase import org.jetbrains.kotlin.fir.declarations.builder.buildNamedFunction import org.jetbrains.kotlin.fir.declarations.builder.buildRegularClass import org.jetbrains.kotlin.fir.declarations.builder.buildValueParameter import org.jetbrains.kotlin.fir.declarations.impl.FirResolvedDeclarationStatusImpl import org.jetbrains.kotlin.fir.declarations.origin import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotation import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationArgumentMapping import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.NestedClassGenerationContext import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.moduleData import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.scopes.kotlinScopeProvider import org.jetbrains.kotlin.fir.symbols.impl.ConeClassLikeLookupTagImpl import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirNamedFunctionSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol import org.jetbrains.kotlin.fir.toEffectiveVisibility import org.jetbrains.kotlin.fir.toFirResolvedTypeRef import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjectionOut import org.jetbrains.kotlin.fir.types.ConeStarProjection import org.jetbrains.kotlin.fir.types.impl.ConeClassLikeTypeImpl import org.jetbrains.kotlin.name.CallableId import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.Keys import software.amazon.app.platform.metro.compiler.fir.buildAnnotationCallWithArgument import software.amazon.app.platform.metro.compiler.fir.buildClassExpression import software.amazon.app.platform.metro.compiler.fir.buildSimpleAnnotationCall import software.amazon.app.platform.metro.compiler.fir.hasAnnotation /** * Generates the declaration shape for `@ContributesRenderer` classes. * * Pseudo Kotlin: * ```kotlin * @ContributesRenderer * class TestRenderer : Renderer { * * @ContributesTo(RendererScope::class) * @Origin(TestRenderer::class) * interface RendererContribution { * @Provides * fun provideTestRenderer(): TestRenderer * * @Binds * @IntoMap * @RendererKey(Model::class) * fun provideTestRendererModel(renderer: TestRenderer): Renderer<*> * * @Provides * @IntoMap * @RendererKey(Model::class) * @ForScope(RendererScope::class) * fun provideTestRendererModelKey(): KClass> * } * } * ``` * * No top-level graph interface is generated. If the renderer has an `@Inject` constructor, the * nested declaration omits `provideTestRenderer()` and only the map bindings are generated. When * `includeSealedSubtypes` is enabled, the `Model` binding pair is generated once per collected * model subtype. */ public class ContributesRendererFir(session: FirSession) : MetroFirDeclarationGenerationExtension(session) { override fun FirDeclarationPredicateRegistrar.registerPredicates() { register(ContributesRendererIds.PREDICATE) } override fun getContributionHints(): List { return annotatedRendererClasses().map { classSymbol -> ContributionHint( contributingClassId = classSymbol.classId.createNestedClassId(ContributesRendererIds.NESTED_INTERFACE_NAME), scope = ClassIds.RENDERER_SCOPE, ) } } override fun getNestedClassifiersNames( classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext, ): Set { return if ( hasAnnotation(classSymbol, ContributesRendererIds.CONTRIBUTES_RENDERER_CLASS_ID, session) ) { setOf(ContributesRendererIds.NESTED_INTERFACE_NAME) } else { emptySet() } } override fun generateNestedClassLikeDeclaration( owner: FirClassSymbol<*>, name: Name, context: NestedClassGenerationContext, ): FirClassLikeSymbol<*>? { if (name != ContributesRendererIds.NESTED_INTERFACE_NAME) return null if (!hasAnnotation(owner, ContributesRendererIds.CONTRIBUTES_RENDERER_CLASS_ID, session)) return null val rendererOwner = owner as? FirRegularClassSymbol ?: return null val metadata = rendererContributionMetadata(rendererOwner, session) ?: return null val nestedClassId = rendererOwner.classId.createNestedClassId(name) val graphSymbol = FirRegularClassSymbol(nestedClassId) buildRegularClass { resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRendererGeneratorKey.origin source = rendererOwner.source classKind = ClassKind.INTERFACE scopeProvider = session.kotlinScopeProvider this.name = nestedClassId.shortClassName symbol = graphSymbol status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.ABSTRACT, Visibilities.Public.toEffectiveVisibility(rendererOwner, forClass = true), ) superTypeRefs += session.builtinTypes.anyType annotations += buildReflectionContributesToAnnotation() annotations += buildOriginAnnotation(rendererOwner, graphSymbol) for (function in buildProvidesFunctions(nestedClassId, rendererOwner, metadata)) { declarations += function } } return graphSymbol } private fun annotatedRendererClasses(): List { return session.predicateBasedProvider .getSymbolsByPredicate(ContributesRendererIds.PREDICATE) .filterIsInstance() .toList() } private fun buildProvidesFunctions( graphClassId: ClassId, owner: FirRegularClassSymbol, metadata: RendererContributionMetadata, includeRendererProvider: Boolean = true, ): List { val functions = mutableListOf() if (includeRendererProvider && !metadata.hasInjectAnnotation) { functions += buildProvideRendererFunction(graphClassId, owner) } metadata.modelClasses.forEach { modelClass -> functions += buildProvideRendererIntoMapFunction(graphClassId, owner, modelClass) functions += buildProvideRendererKeyFunction(graphClassId, owner, modelClass) } return functions } private fun buildProvideRendererFunction( graphClassId: ClassId, owner: FirRegularClassSymbol, ): FirFunction { val functionName = "provide${ContributesRendererIds.generatedSafeClassNamePrefix(owner.classId)}" val callableId = CallableId(graphClassId, Name.identifier(functionName)) val functionSymbol = FirNamedFunctionSymbol(callableId) return buildNamedFunction { isLocal = false resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRendererGeneratorKey.origin source = owner.source symbol = functionSymbol name = callableId.callableName returnTypeRef = owner.defaultType().toFirResolvedTypeRef() dispatchReceiverType = generatedGraphType(graphClassId) status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.OPEN, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) annotations += buildSimpleAnnotationCall(ClassIds.PROVIDES, functionSymbol, session) } } private fun buildProvideRendererIntoMapFunction( graphClassId: ClassId, owner: FirRegularClassSymbol, modelClass: ResolvedModelClass, ): FirFunction { val functionName = "provide${ContributesRendererIds.generatedSafeClassNamePrefix(owner.classId)}" + ContributesRendererIds.generatedModelClassNameSuffix(modelClass.classId) val callableId = CallableId(graphClassId, Name.identifier(functionName)) val functionSymbol = FirNamedFunctionSymbol(callableId) val ownerType = owner.defaultType() return buildNamedFunction { isLocal = false resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRendererGeneratorKey.origin source = owner.source symbol = functionSymbol name = callableId.callableName returnTypeRef = rendererStarType().toFirResolvedTypeRef() dispatchReceiverType = generatedGraphType(graphClassId) status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.OPEN, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) valueParameters += buildValueParameter { resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRendererGeneratorKey.origin source = owner.source returnTypeRef = ownerType.toFirResolvedTypeRef() name = Name.identifier("renderer") symbol = FirValueParameterSymbol() containingDeclarationSymbol = functionSymbol } annotations += buildSimpleAnnotationCall(ClassIds.BINDS, functionSymbol, session) annotations += buildSimpleAnnotationCall(ClassIds.INTO_MAP, functionSymbol, session) annotations += buildRendererKeyAnnotation(owner, modelClass, functionSymbol) } } private fun buildProvideRendererKeyFunction( graphClassId: ClassId, owner: FirRegularClassSymbol, modelClass: ResolvedModelClass, ): FirFunction { val functionName = "provide${ContributesRendererIds.generatedSafeClassNamePrefix(owner.classId)}" + ContributesRendererIds.generatedModelClassNameSuffix(modelClass.classId) + "Key" val callableId = CallableId(graphClassId, Name.identifier(functionName)) val functionSymbol = FirNamedFunctionSymbol(callableId) return buildNamedFunction { isLocal = false resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRendererGeneratorKey.origin source = owner.source symbol = functionSymbol name = callableId.callableName returnTypeRef = kClassProducerOf(rendererStarType()).toFirResolvedTypeRef() dispatchReceiverType = generatedGraphType(graphClassId) status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.OPEN, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) annotations += buildSimpleAnnotationCall(ClassIds.PROVIDES, functionSymbol, session) annotations += buildSimpleAnnotationCall(ClassIds.INTO_MAP, functionSymbol, session) annotations += buildRendererKeyAnnotation(owner, modelClass, functionSymbol) annotations += buildForScopeAnnotation(functionSymbol) } } private fun generatedGraphType(graphClassId: ClassId) = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(graphClassId), emptyArray(), isMarkedNullable = false, ) private fun rendererStarType() = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(ClassIds.RENDERER), arrayOf(ConeStarProjection), isMarkedNullable = false, ) private fun kClassProducerOf(type: ConeKotlinType) = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(ClassId(FqName("kotlin.reflect"), Name.identifier("KClass"))), arrayOf(ConeKotlinTypeProjectionOut(type)), isMarkedNullable = false, ) private fun buildReflectionContributesToAnnotation() = buildAnnotation { val contributesToSymbol = session.symbolProvider.getClassLikeSymbolByClassId(ClassIds.CONTRIBUTES_TO) as? FirRegularClassSymbol ?: error("Annotation class ${ClassIds.CONTRIBUTES_TO} not found on the classpath") annotationTypeRef = contributesToSymbol.defaultType().toFirResolvedTypeRef() argumentMapping = buildAnnotationArgumentMapping { mapping[Name.identifier("scope")] = buildClassExpression(ClassIds.RENDERER_SCOPE, session) } } private fun buildForScopeAnnotation(containingSymbol: FirNamedFunctionSymbol) = buildAnnotationCallWithArgument( classId = ClassIds.FOR_SCOPE, argName = Name.identifier("scope"), argument = buildClassExpression(ClassIds.RENDERER_SCOPE, session), containingSymbol = containingSymbol, session = session, ) private fun buildOriginAnnotation( owner: FirRegularClassSymbol, containingSymbol: FirRegularClassSymbol, ) = buildAnnotationCallWithArgument( classId = ClassIds.ORIGIN, argName = Name.identifier("value"), argument = buildClassExpression(owner, session), containingSymbol = containingSymbol, session = session, ) private fun buildRendererKeyAnnotation( owner: FirRegularClassSymbol, modelClass: ResolvedModelClass, containingSymbol: FirNamedFunctionSymbol, ) = buildAnnotationCallWithArgument( classId = ClassIds.RENDERER_KEY, argName = Name.identifier("value"), argument = modelClass.classSymbol?.let { buildClassExpression(it, session) } ?: buildClassExpression(modelClass.classId, session, owner), containingSymbol = containingSymbol, session = session, ) @AutoService(MetroFirDeclarationGenerationExtension.Factory::class) public class Factory : MetroFirDeclarationGenerationExtension.Factory { override fun create( session: FirSession, options: MetroOptions, compatContext: CompatContext, ): MetroFirDeclarationGenerationExtension = ContributesRendererFir(session) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/renderer/ContributesRendererIds.kt ================================================ package software.amazon.app.platform.metro.compiler.renderer import org.jetbrains.kotlin.fir.extensions.predicate.LookupPredicate import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds internal object ContributesRendererIds { val CONTRIBUTES_RENDERER_CLASS_ID = ClassIds.CONTRIBUTES_RENDERER val CONTRIBUTES_RENDERER_FQ_NAME = ClassIds.CONTRIBUTES_RENDERER.asSingleFqName() val NESTED_INTERFACE_NAME: Name = Name.identifier("RendererContribution") val PREDICATE = LookupPredicate.create { annotated(CONTRIBUTES_RENDERER_FQ_NAME) } fun generatedSafeClassNamePrefix(contributingClassId: ClassId): String { return (contributingClassId.packageFqName.pathSegments() + contributingClassId.relativeClassName.pathSegments()) .joinToString(separator = "") { it.asString().replaceFirstChar(Char::uppercase) } } fun generatedModelClassNameSuffix(modelClassId: ClassId): String { return modelClassId.relativeClassName.pathSegments().joinToString(separator = "") { it.asString() } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/renderer/ContributesRendererIrExtension.kt ================================================ package software.amazon.app.platform.metro.compiler.renderer import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder import org.jetbrains.kotlin.ir.IrStatement import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET import org.jetbrains.kotlin.ir.builders.irBlockBody import org.jetbrains.kotlin.ir.builders.irCallConstructor import org.jetbrains.kotlin.ir.builders.irReturn import org.jetbrains.kotlin.ir.declarations.IrClass import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin import org.jetbrains.kotlin.ir.declarations.IrModuleFragment import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.expressions.IrClassReference import org.jetbrains.kotlin.ir.expressions.impl.IrClassReferenceImpl import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrSimpleType import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.types.defaultType import org.jetbrains.kotlin.ir.types.typeWith import org.jetbrains.kotlin.ir.util.constructors import org.jetbrains.kotlin.ir.util.parentAsClass import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.Keys /** * Fills in the bodies for FIR-generated nested `@ContributesRenderer` graph functions. * * Pseudo Kotlin for `TestRenderer.RendererContribution`: * ```kotlin * @ContributesRenderer * class TestRenderer : Renderer { * * @ContributesTo(RendererScope::class) * @Origin(TestRenderer::class) * interface RendererContribution { * @Provides * fun provideTestRenderer(): TestRenderer = TestRenderer() * * @Binds * @IntoMap * @RendererKey(Model::class) * fun provideTestRendererModel(renderer: TestRenderer): Renderer<*> * * @Provides * @IntoMap * @RendererKey(Model::class) * @ForScope(RendererScope::class) * fun provideTestRendererModelKey(): KClass> = TestRenderer::class * } * } * ``` * * The direct constructor call is only generated for zero-arg renderers. The `@IntoMap` renderer * binding stays abstract and is handled by Metro's normal `@Binds` support. */ @Suppress("DEPRECATION") internal class ContributesRendererIrExtension : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { moduleFragment.transformChildrenVoid(ContributesRendererIrTransformer(pluginContext)) } } @Suppress("DEPRECATION") @OptIn(UnsafeDuringIrConstructionAPI::class) private class ContributesRendererIrTransformer(private val pluginContext: IrPluginContext) : IrElementTransformerVoid() { override fun visitSimpleFunction(declaration: IrSimpleFunction): IrStatement { val origin = declaration.origin if ( origin !is IrDeclarationOrigin.GeneratedByPlugin || origin.pluginKey != Keys.ContributesRendererGeneratorKey ) { return super.visitSimpleFunction(declaration) } if (declaration.body != null) return super.visitSimpleFunction(declaration) if (!declaration.name.asString().startsWith("provide")) { return super.visitSimpleFunction(declaration) } when { declaration.name.asString().endsWith("Key") -> generateProvideRendererKeyBody(declaration) declaration.parameters.none { it.name.asString() == "renderer" } -> generateProvideRendererBody(declaration) } return super.visitSimpleFunction(declaration) } private fun generateProvideRendererBody(declaration: IrSimpleFunction) { val classSymbol = (declaration.returnType as? IrSimpleType)?.classOrNull ?: return val constructor = classSymbol.constructors.singleOrNull { it.owner.parameters.isEmpty() } ?: return val irBuilder = irBuilderFor(declaration) declaration.body = irBuilder.irBlockBody { val constructorCall = irCallConstructor(constructor, emptyList()) constructorCall.startOffset = UNDEFINED_OFFSET constructorCall.endOffset = UNDEFINED_OFFSET +irReturn(constructorCall) } } private fun generateProvideRendererKeyBody(declaration: IrSimpleFunction) { val ownerClassSymbol = generatedOwnerClass(declaration)?.symbol ?: return val irBuilder = irBuilderFor(declaration) declaration.body = irBuilder.irBlockBody { +irReturn( IrClassReferenceImpl( UNDEFINED_OFFSET, UNDEFINED_OFFSET, pluginContext.irBuiltIns.kClassClass.typeWith(ownerClassSymbol.defaultType), ownerClassSymbol, ownerClassSymbol.defaultType, ) ) } } private fun generatedOwnerClass(declaration: IrSimpleFunction): IrClass? { val parentClass = declaration.parent as? IrClass ?: return null val originAnnotation = parentClass.annotations.firstOrNull { annotation -> annotation.symbol.owner.parentAsClass.name == ClassIds.ORIGIN.shortClassName } ?: return null val classReference = originAnnotation.arguments[0] as? IrClassReference ?: return null return classReference.classType.classOrNull?.owner } private fun irBuilderFor(declaration: IrSimpleFunction) = DeclarationIrBuilder(pluginContext, declaration.symbol, UNDEFINED_OFFSET, UNDEFINED_OFFSET) } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/renderer/ContributesRendererMetroExtension.kt ================================================ package software.amazon.app.platform.metro.compiler.renderer import com.google.auto.service.AutoService import dev.zacsweers.metro.compiler.MetroOptions import dev.zacsweers.metro.compiler.api.fir.MetroContributionExtension import dev.zacsweers.metro.compiler.compat.CompatContext import dev.zacsweers.metro.compiler.fir.MetroFirTypeResolver import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.scopes.getSingleClassifier import org.jetbrains.kotlin.fir.scopes.impl.declaredMemberScope import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.name.ClassId import software.amazon.app.platform.metro.compiler.ClassIds public class ContributesRendererMetroExtension(private val session: FirSession) : MetroContributionExtension { private val predicate = ContributesRendererIds.PREDICATE private val annotatedClasses by lazy { session.predicateBasedProvider .getSymbolsByPredicate(predicate) .filterIsInstance() .toList() } override fun FirDeclarationPredicateRegistrar.registerPredicates() { register(predicate) } override fun getContributions( scopeClassId: ClassId, typeResolverFactory: MetroFirTypeResolver.Factory, ): List { if (scopeClassId != ClassIds.RENDERER_SCOPE) return emptyList() return annotatedClasses.mapNotNull { parentSymbol -> val contributionInterfaceClassId = parentSymbol.classId.createNestedClassId(ContributesRendererIds.NESTED_INTERFACE_NAME) val contributionSymbol = session.symbolProvider.getClassLikeSymbolByClassId(contributionInterfaceClassId) as? FirRegularClassSymbol ?: return@mapNotNull null val scope = contributionSymbol.declaredMemberScope(session, memberRequiredPhase = null) val metroContributionName = scope.getClassifierNames().firstOrNull { it.identifier.startsWith("MetroContributionTo") } ?: return@mapNotNull null val metroContributionSymbol = scope.getSingleClassifier(metroContributionName) as? FirRegularClassSymbol ?: return@mapNotNull null MetroContributionExtension.Contribution( supertype = metroContributionSymbol.defaultType(), replaces = emptyList(), originClassId = parentSymbol.classId, ) } } @AutoService(MetroContributionExtension.Factory::class) public class Factory : MetroContributionExtension.Factory { override fun create( session: FirSession, options: MetroOptions, compatContext: CompatContext, ): MetroContributionExtension { return ContributesRendererMetroExtension(session) } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/renderer/ContributesRendererSupport.kt ================================================ package software.amazon.app.platform.metro.compiler.renderer import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirDeclaration import org.jetbrains.kotlin.fir.declarations.FirFile import org.jetbrains.kotlin.fir.declarations.FirRegularClass import org.jetbrains.kotlin.fir.declarations.getSealedClassInheritors import org.jetbrains.kotlin.fir.declarations.utils.isSealed import org.jetbrains.kotlin.fir.expressions.FirAnnotationCall import org.jetbrains.kotlin.fir.expressions.FirLiteralExpression import org.jetbrains.kotlin.fir.expressions.FirNamedArgumentExpression import org.jetbrains.kotlin.fir.moduleData import org.jetbrains.kotlin.fir.resolve.fullyExpandedType import org.jetbrains.kotlin.fir.resolve.providers.firProvider import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjection import org.jetbrains.kotlin.fir.types.classId import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.fir.allSessions import software.amazon.app.platform.metro.compiler.fir.findAnnotation import software.amazon.app.platform.metro.compiler.fir.findClassLikeSymbolInContainingFile import software.amazon.app.platform.metro.compiler.fir.findClassLikeSymbolInPackageFiles import software.amazon.app.platform.metro.compiler.fir.findContainingFile import software.amazon.app.platform.metro.compiler.fir.hasAnnotation import software.amazon.app.platform.metro.compiler.fir.resolveClassIdArgument import software.amazon.app.platform.metro.compiler.fir.resolveClassReferenceArgument import software.amazon.app.platform.metro.compiler.fir.resolveDeclaredSuperTypes import software.amazon.app.platform.metro.compiler.fir.unwrapArgumentExpression internal data class ResolvedModelClass( val classId: ClassId, val classSymbol: FirRegularClassSymbol?, ) internal data class RendererContributionMetadata( val hasInjectAnnotation: Boolean, val modelClasses: List, ) internal sealed interface RendererModelTypeResolution { data class Success(val modelClass: ResolvedModelClass) : RendererModelTypeResolution data class Error(val message: String) : RendererModelTypeResolution } internal fun rendererContributionMetadata( classSymbol: FirRegularClassSymbol, session: FirSession, ): RendererContributionMetadata? { val modelType = resolveRendererModelType(classSymbol, session) as? RendererModelTypeResolution.Success ?: return null val includeSealedSubtypes = contributesRendererIncludeSealedSubtypes(classSymbol, session) return RendererContributionMetadata( hasInjectAnnotation = hasAnnotation(classSymbol, ClassIds.INJECT, session), modelClasses = if (includeSealedSubtypes) { collectModelClasses(modelType.modelClass, session) } else { listOf(modelType.modelClass) }, ) } internal fun resolveRendererModelType( classSymbol: FirRegularClassSymbol, session: FirSession, ): RendererModelTypeResolution { explicitRendererModelType(classSymbol, session)?.let { return RendererModelTypeResolution.Success(it) } val implicitModelTypes = implicitRendererModelTypes(classSymbol, session) return if (implicitModelTypes.size == 1) { RendererModelTypeResolution.Success(implicitModelTypes.single()) } else { RendererModelTypeResolution.Error( buildString { append( "Couldn't find BaseModel type for ${classSymbol.name.asString()}. Consider adding " + "an explicit parameter." ) if (implicitModelTypes.size > 1) { append("Found: ") append(implicitModelTypes.joinToString { it.classId.asSingleFqName().asString() }) } } ) } } internal fun isSingleInRendererScope( classSymbol: FirRegularClassSymbol, session: FirSession, ): Boolean { val annotation = findAnnotation(classSymbol, ClassIds.SINGLE_IN, session) as? FirAnnotationCall ?: return false val rawScopeArgument = annotation.argumentMapping.mapping[Name.identifier("scope")] ?: annotation.argumentList.arguments.firstOrNull() ?: return false return resolveClassIdArgument(rawScopeArgument, classSymbol, session) == ClassIds.RENDERER_SCOPE } @OptIn(DirectDeclarationsAccess::class, SymbolInternals::class) internal fun constructorParameterCount(classSymbol: FirRegularClassSymbol): Int { val constructorSymbol = classSymbol.declarationSymbols.filterIsInstance().firstOrNull { it.isPrimary } ?: classSymbol.declarationSymbols.filterIsInstance().firstOrNull() return constructorSymbol?.fir?.valueParameters?.size ?: 0 } private fun explicitRendererModelType( classSymbol: FirRegularClassSymbol, session: FirSession, ): ResolvedModelClass? { val annotation = findAnnotation(classSymbol, ContributesRendererIds.CONTRIBUTES_RENDERER_CLASS_ID, session) as? FirAnnotationCall ?: return null val rawModelTypeArgument = annotation.argumentMapping.mapping[Name.identifier("modelType")] ?: annotation.argumentList.arguments.firstOrNull() ?: return null return resolveClassReferenceArgument(rawModelTypeArgument, classSymbol, session) ?.takeIf { it.classId != ClassIds.UNIT } ?.let { val resolvedClassSymbol = it.classSymbol ?: (session.symbolProvider.getClassLikeSymbolByClassId(it.classId) as? FirRegularClassSymbol) ?: findClassLikeSymbolInContainingFile(classSymbol, it.classId, session) ?: findClassLikeSymbolInPackageFiles( classSymbol.classId.packageFqName, it.classId, session, ) ResolvedModelClass(classId = it.classId, classSymbol = resolvedClassSymbol) } } private fun contributesRendererIncludeSealedSubtypes( classSymbol: FirRegularClassSymbol, session: FirSession, ): Boolean { val annotation = findAnnotation(classSymbol, ContributesRendererIds.CONTRIBUTES_RENDERER_CLASS_ID, session) as? FirAnnotationCall ?: return true val rawArgument = annotation.argumentMapping.mapping[Name.identifier("includeSealedSubtypes")] ?: annotation.argumentList.arguments.firstOrNull { argument -> (argument as? FirNamedArgumentExpression)?.name == Name.identifier("includeSealedSubtypes") } val expression = rawArgument?.let(::unwrapArgumentExpression) ?: return true return (expression as? FirLiteralExpression)?.value as? Boolean ?: true } private fun implicitRendererModelTypes( classSymbol: FirRegularClassSymbol, session: FirSession, ): List { val collected = linkedMapOf() val visited = mutableSetOf() val queue = ArrayDeque() queue += resolveDeclaredSuperTypes(classSymbol, session) while (queue.isNotEmpty()) { val type = queue.removeFirst().fullyExpandedType(session) if (!visited.add(type)) continue collectImplicitModelTypes(type, session).forEach { modelClass -> collected.putIfAbsent(modelClass.classId, modelClass) } val typeSymbol = type.toRegularClassSymbol(session) ?: continue queue += resolveDeclaredSuperTypes(typeSymbol, session, actualType = type) } return collected.values.toList() } private fun collectImplicitModelTypes( type: ConeKotlinType, session: FirSession, ): List { return type.typeArguments .asSequence() .mapNotNull { projection -> val candidateType = (projection as? ConeKotlinTypeProjection)?.type?.fullyExpandedType(session) ?: return@mapNotNull null val candidateSymbol = candidateType.toRegularClassSymbol(session) if (candidateSymbol == null || candidateSymbol.classId == ClassIds.BASE_MODEL) { return@mapNotNull null } if (!isBaseModelSubtype(candidateType, session)) return@mapNotNull null ResolvedModelClass(candidateSymbol.classId, candidateSymbol) } .toList() } private fun isBaseModelSubtype( type: ConeKotlinType, session: FirSession, visited: MutableSet = mutableSetOf(), ): Boolean { val expandedType = type.fullyExpandedType(session) if (!visited.add(expandedType)) return false val classSymbol = expandedType.toRegularClassSymbol(session) ?: return false if (classSymbol.classId == ClassIds.BASE_MODEL) return true return resolveDeclaredSuperTypes(classSymbol, session, actualType = expandedType).any { isBaseModelSubtype(it, session, visited) } } @OptIn(SymbolInternals::class) private fun collectModelClasses( rootModelClass: ResolvedModelClass, session: FirSession, ): List { val collected = linkedMapOf() val queue = ArrayDeque() queue += rootModelClass while (queue.isNotEmpty()) { val modelClass = queue.removeFirst() if (collected.putIfAbsent(modelClass.classId, modelClass) != null) continue val classSymbol = modelClass.classSymbol ?: (session.symbolProvider.getClassLikeSymbolByClassId(modelClass.classId) as? FirRegularClassSymbol) ?: continue val sealedInheritors = findDirectSealedInheritors(classSymbol, session) if (sealedInheritors.isEmpty()) continue for ((classId, symbol) in sealedInheritors) { queue += ResolvedModelClass(classId = classId, classSymbol = symbol) } } return collected.values.toList() } @OptIn(SymbolInternals::class) private fun findDirectSealedInheritors( classSymbol: FirRegularClassSymbol, session: FirSession, ): List> { if (!classSymbol.fir.isSealed) return emptyList() val collected = linkedMapOf() findDirectSealedInheritorsFromMetadata(classSymbol, session).forEach { (classId, symbol) -> collected.putIfAbsent(classId, symbol) } findDirectSealedInheritorsInSource(classSymbol, session).forEach { symbol -> collected[symbol.classId] = collected[symbol.classId] ?: symbol } return collected.map { (classId, symbol) -> classId to symbol } } @OptIn(SymbolInternals::class) private fun findDirectSealedInheritorsFromMetadata( classSymbol: FirRegularClassSymbol, session: FirSession, ): List> { val ownerSession = classSymbol.fir.moduleData.session return classSymbol.fir.getSealedClassInheritors(ownerSession).distinct().map { classId -> classId to ((ownerSession.symbolProvider.getClassLikeSymbolByClassId(classId) ?: session.symbolProvider.getClassLikeSymbolByClassId(classId)) as? FirRegularClassSymbol) } } @OptIn(DirectDeclarationsAccess::class, SymbolInternals::class) private fun findDirectSealedInheritorsInSource( classSymbol: FirRegularClassSymbol, session: FirSession, ): List { val visitedFiles = linkedSetOf() val candidates = linkedMapOf() fun visitFile(file: FirFile) { if (!visitedFiles.add(file)) return collectRegularClasses(file.declarations).forEach { candidate -> if (candidate.classId == classSymbol.classId) return@forEach val hasDirectSupertype = resolveDeclaredSuperTypes(candidate, session).any { superType -> superType.toRegularClassSymbol(session)?.classId == classSymbol.classId } if (hasDirectSupertype) { candidates.putIfAbsent(candidate.classId, candidate) } } } findContainingFile(classSymbol, session)?.let(::visitFile) allSessions(session).forEach { candidateSession -> candidateSession.firProvider .getFirFilesByPackage(classSymbol.classId.packageFqName) .forEach(::visitFile) } return candidates.values.toList() } @OptIn(DirectDeclarationsAccess::class) private fun collectRegularClasses( declarations: List ): Sequence { return declarations.asSequence().flatMap { declaration -> val regularClass = declaration as? FirRegularClass ?: return@flatMap emptySequence() sequenceOf(regularClass.symbol) + collectRegularClasses(regularClass.declarations) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/robot/ContributesRobotChecker.kt ================================================ package software.amazon.app.platform.metro.compiler.robot import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.diagnostics.DiagnosticReporter import org.jetbrains.kotlin.diagnostics.reportOn import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirClassChecker import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirClass import org.jetbrains.kotlin.fir.declarations.FirConstructor import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe import org.jetbrains.kotlin.fir.resolve.fullyExpandedType import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.types.coneType import org.jetbrains.kotlin.name.ClassId import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.fir.AppPlatformMetroExtensionsDiagnostics import software.amazon.app.platform.metro.compiler.fir.extractScopeClassId import software.amazon.app.platform.metro.compiler.fir.hasAnnotation import software.amazon.app.platform.metro.compiler.fir.hasTransitiveSupertype internal object ContributesRobotChecker : FirClassChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirClass) { declaration.source ?: return val session = context.session val annotation = declaration.annotations.firstOrNull { candidate -> candidate.toAnnotationClassId(session) == ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID } ?: return val classSymbol = declaration.symbol as? FirRegularClassSymbol ?: return if (declaration.classKind != ClassKind.CLASS) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_ROBOT_ERROR, "@ContributesRobot can only be applied to classes, not " + "${declaration.classKind.name.lowercase().replace('_', ' ')}s.", ) return } if (!implementsRobot(declaration, session)) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_ROBOT_ERROR, "In order to use @ContributesRobot, ${classSymbol.name.asString()} must implement " + "${ClassIds.ROBOT.asSingleFqName()}.", ) } if ( requiresInjectAnnotation(declaration) && !hasAnnotation(classSymbol, ClassIds.INJECT, session) ) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_ROBOT_ERROR, "${classSymbol.name.asString()} must be annotated with @Inject when injecting arguments " + "into a robot.", ) } val singletonAnnotation = declaration.annotations.firstOrNull { candidate -> isMetroScopeAnnotation(candidate.toAnnotationClassIdSafe(session), session) } if (singletonAnnotation != null) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_ROBOT_ERROR, "It's not allowed for a robot to be a singleton, because the lifetime of the " + "robot is scoped to the robot() factory function. Remove the @" + singletonAnnotation.toAnnotationClassIdSafe(session)?.shortClassName?.asString() + " annotation.", ) } val scopeClassId = extractScopeClassId(classSymbol, ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID, session) if (scopeClassId != null && scopeClassId != ClassIds.APP_SCOPE) { reporter.reportOn( annotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_ROBOT_ERROR, "Robots can only be contributed to the AppScope for now. Scope " + "${scopeClassId.asSingleFqName()} is unsupported.", ) } } private fun implementsRobot(declaration: FirClass, session: FirSession): Boolean { return declaration.superTypeRefs.any { superTypeRef -> val coneType = superTypeRef.coneType.fullyExpandedType(session) hasTransitiveSupertype(coneType, session, ClassIds.ROBOT_FQ_NAMES) } } @OptIn(DirectDeclarationsAccess::class) private fun requiresInjectAnnotation(declaration: FirClass): Boolean { val constructor = declaration.declarations.filterIsInstance().firstOrNull { it.isPrimary } ?: declaration.declarations.filterIsInstance().firstOrNull() return constructor?.valueParameters?.isNotEmpty() == true } private fun isMetroScopeAnnotation(annotationClassId: ClassId?, session: FirSession): Boolean { val resolvedClassId = annotationClassId ?: return false val annotationSymbol = session.symbolProvider.getClassLikeSymbolByClassId(resolvedClassId) as? FirClassSymbol<*> ?: return false return annotationSymbol.resolvedCompilerAnnotationsWithClassIds.any { it.toAnnotationClassIdSafe(session) == ClassIds.SCOPE } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/robot/ContributesRobotFir.kt ================================================ package software.amazon.app.platform.metro.compiler.robot import com.google.auto.service.AutoService import dev.zacsweers.metro.compiler.MetroOptions import dev.zacsweers.metro.compiler.api.fir.MetroFirDeclarationGenerationExtension import dev.zacsweers.metro.compiler.compat.CompatContext import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.descriptors.Modality import org.jetbrains.kotlin.descriptors.Visibilities import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.FirFunction import org.jetbrains.kotlin.fir.declarations.FirResolvePhase import org.jetbrains.kotlin.fir.declarations.builder.buildNamedFunction import org.jetbrains.kotlin.fir.declarations.builder.buildRegularClass import org.jetbrains.kotlin.fir.declarations.builder.buildValueParameter import org.jetbrains.kotlin.fir.declarations.impl.FirResolvedDeclarationStatusImpl import org.jetbrains.kotlin.fir.declarations.origin import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotation import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationArgumentMapping import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.NestedClassGenerationContext import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.moduleData import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.scopes.kotlinScopeProvider import org.jetbrains.kotlin.fir.symbols.impl.ConeClassLikeLookupTagImpl import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirNamedFunctionSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol import org.jetbrains.kotlin.fir.toEffectiveVisibility import org.jetbrains.kotlin.fir.toFirResolvedTypeRef import org.jetbrains.kotlin.fir.types.impl.ConeClassLikeTypeImpl import org.jetbrains.kotlin.name.CallableId import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.Keys import software.amazon.app.platform.metro.compiler.fir.buildAnnotationCallWithArgument import software.amazon.app.platform.metro.compiler.fir.buildClassExpression import software.amazon.app.platform.metro.compiler.fir.buildSimpleAnnotationCall import software.amazon.app.platform.metro.compiler.fir.extractScopeArgument import software.amazon.app.platform.metro.compiler.fir.extractScopeClassId import software.amazon.app.platform.metro.compiler.fir.hasAnnotation /** * Generates the declaration shape for `@ContributesRobot` classes. * * Pseudo Kotlin: * ```kotlin * @ContributesRobot(AppScope::class) * class TestRobot : Robot { * * @ContributesTo(AppScope::class) * interface RobotContribution { * @Provides * fun provideTestRobot(): TestRobot * * @Binds * @IntoMap * @RobotKey(TestRobot::class) * fun provideTestRobotIntoMap(robot: TestRobot): Robot * } * } * ``` * * No top-level graph interface is generated. If the robot class is already `@Inject`-constructible, * the nested declaration omits `provideTestRobot()` and only synthesizes the map-binding method. */ public class ContributesRobotFir(session: FirSession) : MetroFirDeclarationGenerationExtension(session) { override fun FirDeclarationPredicateRegistrar.registerPredicates() { register(ContributesRobotIds.PREDICATE) } override fun getContributionHints(): List { return annotatedRobotClasses().mapNotNull { classSymbol -> val scopeClassId = extractScopeClassId(classSymbol, ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID, session) ?: return@mapNotNull null ContributionHint( contributingClassId = classSymbol.classId.createNestedClassId(ContributesRobotIds.NESTED_INTERFACE_NAME), scope = scopeClassId, ) } } override fun getNestedClassifiersNames( classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext, ): Set { return if ( hasAnnotation(classSymbol, ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID, session) ) { setOf(ContributesRobotIds.NESTED_INTERFACE_NAME) } else { emptySet() } } override fun generateNestedClassLikeDeclaration( owner: FirClassSymbol<*>, name: Name, context: NestedClassGenerationContext, ): FirClassLikeSymbol<*>? { if (name != ContributesRobotIds.NESTED_INTERFACE_NAME) return null if (!hasAnnotation(owner, ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID, session)) return null val scopeArg = extractScopeArgument(owner, ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID, session) ?: return null val nestedClassId = owner.classId.createNestedClassId(name) val classSymbol = FirRegularClassSymbol(nestedClassId) buildRegularClass { resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRobotGeneratorKey.origin source = owner.source classKind = ClassKind.INTERFACE scopeProvider = session.kotlinScopeProvider this.name = nestedClassId.shortClassName symbol = classSymbol status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.ABSTRACT, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) superTypeRefs += session.builtinTypes.anyType annotations += buildAnnotationCallWithArgument( ClassIds.CONTRIBUTES_TO, Name.identifier("scope"), scopeArg, classSymbol, session, ) annotations += buildOriginAnnotation(owner) for (function in buildProvidesFunctions(nestedClassId, owner)) { declarations += function } } return classSymbol } private fun annotatedRobotClasses(): List { return session.predicateBasedProvider .getSymbolsByPredicate(ContributesRobotIds.PREDICATE) .filterIsInstance() .toList() } private fun buildProvidesFunctions( graphClassId: ClassId, owner: FirClassSymbol<*>, ): List { val functions = mutableListOf() if (!hasAnnotation(owner, ClassIds.INJECT, session)) { functions += buildProvideRobotFunction(graphClassId, owner) } functions += buildProvideRobotIntoMapFunction(graphClassId, owner) return functions } private fun buildProvideRobotFunction( graphClassId: ClassId, owner: FirClassSymbol<*>, ): FirFunction { val functionName = "provide${ContributesRobotIds.generatedClassNamePrefix(owner.classId)}" val callableId = CallableId(graphClassId, Name.identifier(functionName)) val functionSymbol = FirNamedFunctionSymbol(callableId) return buildNamedFunction { isLocal = false resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRobotGeneratorKey.origin source = owner.source symbol = functionSymbol name = callableId.callableName returnTypeRef = owner.defaultType().toFirResolvedTypeRef() dispatchReceiverType = generatedGraphType(graphClassId) status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.OPEN, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) annotations += buildSimpleAnnotationCall(ClassIds.PROVIDES, functionSymbol, session) } } private fun buildProvideRobotIntoMapFunction( graphClassId: ClassId, owner: FirClassSymbol<*>, ): FirFunction { val functionName = "provide${ContributesRobotIds.generatedClassNamePrefix(owner.classId)}IntoMap" val callableId = CallableId(graphClassId, Name.identifier(functionName)) val functionSymbol = FirNamedFunctionSymbol(callableId) val ownerType = owner.defaultType() return buildNamedFunction { isLocal = false resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRobotGeneratorKey.origin source = owner.source symbol = functionSymbol name = callableId.callableName returnTypeRef = robotType().toFirResolvedTypeRef() dispatchReceiverType = generatedGraphType(graphClassId) status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.OPEN, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) valueParameters += buildValueParameter { resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesRobotGeneratorKey.origin source = owner.source returnTypeRef = ownerType.toFirResolvedTypeRef() name = Name.identifier("robot") symbol = FirValueParameterSymbol() containingDeclarationSymbol = functionSymbol } annotations += buildSimpleAnnotationCall(ClassIds.BINDS, functionSymbol, session) annotations += buildSimpleAnnotationCall(ClassIds.INTO_MAP, functionSymbol, session) annotations += buildRobotKeyAnnotation(owner.classId) } } private fun generatedGraphType(graphClassId: ClassId) = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(graphClassId), emptyArray(), isMarkedNullable = false, ) private fun robotType() = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(ClassIds.ROBOT), emptyArray(), isMarkedNullable = false, ) private fun buildOriginAnnotation(owner: FirClassSymbol<*>) = buildAnnotation { val originSymbol = session.symbolProvider.getClassLikeSymbolByClassId(ClassIds.ORIGIN) as? FirRegularClassSymbol ?: error("Annotation class ${ClassIds.ORIGIN} not found on the classpath") annotationTypeRef = originSymbol.defaultType().toFirResolvedTypeRef() argumentMapping = buildAnnotationArgumentMapping { mapping[Name.identifier("value")] = buildClassExpression(owner, session) } } private fun buildRobotKeyAnnotation(robotClassId: ClassId) = buildAnnotation { val robotKeySymbol = session.symbolProvider.getClassLikeSymbolByClassId(ClassIds.ROBOT_KEY) as? FirRegularClassSymbol ?: error("Annotation class ${ClassIds.ROBOT_KEY} not found on the classpath") annotationTypeRef = robotKeySymbol.defaultType().toFirResolvedTypeRef() argumentMapping = buildAnnotationArgumentMapping { mapping[Name.identifier("value")] = buildClassExpression(robotClassId, session) } } @AutoService(MetroFirDeclarationGenerationExtension.Factory::class) public class Factory : MetroFirDeclarationGenerationExtension.Factory { override fun create( session: FirSession, options: MetroOptions, compatContext: CompatContext, ): MetroFirDeclarationGenerationExtension = ContributesRobotFir(session) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/robot/ContributesRobotIds.kt ================================================ package software.amazon.app.platform.metro.compiler.robot import org.jetbrains.kotlin.fir.extensions.predicate.LookupPredicate import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds internal object ContributesRobotIds { val CONTRIBUTES_ROBOT_CLASS_ID = ClassIds.CONTRIBUTES_ROBOT val CONTRIBUTES_ROBOT_FQ_NAME = FqName("software.amazon.app.platform.inject.robot.ContributesRobot") val NESTED_INTERFACE_NAME: Name = Name.identifier("RobotContribution") val PREDICATE = LookupPredicate.create { annotated(CONTRIBUTES_ROBOT_FQ_NAME) } fun generatedClassNamePrefix(contributingClassId: ClassId): String { return contributingClassId.relativeClassName.pathSegments().joinToString(separator = "") { it.asString() } } fun generatedRobotPropertyName(contributingClassId: ClassId): Name { return Name.identifier( generatedClassNamePrefix(contributingClassId).replaceFirstChar { char -> char.lowercase() } ) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/robot/ContributesRobotIrExtension.kt ================================================ package software.amazon.app.platform.metro.compiler.robot import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder import org.jetbrains.kotlin.ir.IrStatement import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET import org.jetbrains.kotlin.ir.builders.irBlockBody import org.jetbrains.kotlin.ir.builders.irCallConstructor import org.jetbrains.kotlin.ir.builders.irReturn import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin import org.jetbrains.kotlin.ir.declarations.IrModuleFragment import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrSimpleType import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.util.constructors import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid import software.amazon.app.platform.metro.compiler.Keys /** * Fills in the bodies for FIR-generated nested `@ContributesRobot` provider functions. * * Pseudo Kotlin for `TestRobot.RobotContribution`: * ```kotlin * @ContributesRobot(AppScope::class) * class TestRobot : Robot { * * @ContributesTo(AppScope::class) * interface RobotContribution { * @Provides * fun provideTestRobot(): TestRobot = TestRobot() * * @Binds * @IntoMap * @RobotKey(TestRobot::class) * fun provideTestRobotIntoMap(robot: TestRobot): Robot * } * } * ``` * * The direct constructor call is only generated for zero-arg robots. The `@IntoMap` binding stays * abstract and is handled by Metro's normal `@Binds` support. */ @Suppress("DEPRECATION") internal class ContributesRobotIrExtension : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { moduleFragment.transformChildrenVoid(ContributesRobotIrTransformer(pluginContext)) } } @Suppress("DEPRECATION") @OptIn(UnsafeDuringIrConstructionAPI::class) private class ContributesRobotIrTransformer(private val pluginContext: IrPluginContext) : IrElementTransformerVoid() { override fun visitSimpleFunction(declaration: IrSimpleFunction): IrStatement { val origin = declaration.origin if ( origin !is IrDeclarationOrigin.GeneratedByPlugin || origin.pluginKey != Keys.ContributesRobotGeneratorKey ) { return super.visitSimpleFunction(declaration) } if (declaration.body != null) return super.visitSimpleFunction(declaration) if (!declaration.name.asString().startsWith("provide")) return super.visitSimpleFunction(declaration) if (!declaration.name.asString().endsWith("IntoMap")) { generateProvideRobotBody(declaration) } return super.visitSimpleFunction(declaration) } private fun generateProvideRobotBody(declaration: IrSimpleFunction) { val classSymbol = (declaration.returnType as? IrSimpleType)?.classOrNull ?: return val constructor = classSymbol.constructors.singleOrNull { it.owner.parameters.isEmpty() } ?: return val irBuilder = irBuilderFor(declaration) declaration.body = irBuilder.irBlockBody { val constructorCall = irCallConstructor(constructor, emptyList()) constructorCall.startOffset = UNDEFINED_OFFSET constructorCall.endOffset = UNDEFINED_OFFSET +irReturn(constructorCall) } } private fun irBuilderFor(declaration: IrSimpleFunction) = DeclarationIrBuilder(pluginContext, declaration.symbol, UNDEFINED_OFFSET, UNDEFINED_OFFSET) } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/robot/ContributesRobotMetroExtension.kt ================================================ package software.amazon.app.platform.metro.compiler.robot import com.google.auto.service.AutoService import dev.zacsweers.metro.compiler.MetroOptions import dev.zacsweers.metro.compiler.api.fir.MetroContributionExtension import dev.zacsweers.metro.compiler.compat.CompatContext import dev.zacsweers.metro.compiler.fir.MetroFirTypeResolver import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.scopes.getSingleClassifier import org.jetbrains.kotlin.fir.scopes.impl.declaredMemberScope import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.name.ClassId import software.amazon.app.platform.metro.compiler.fir.extractScopeClassId public class ContributesRobotMetroExtension(private val session: FirSession) : MetroContributionExtension { private val predicate = ContributesRobotIds.PREDICATE private val annotatedClasses by lazy { session.predicateBasedProvider .getSymbolsByPredicate(predicate) .filterIsInstance() .toList() } override fun FirDeclarationPredicateRegistrar.registerPredicates() { register(predicate) } override fun getContributions( scopeClassId: ClassId, typeResolverFactory: MetroFirTypeResolver.Factory, ): List { return annotatedClasses.mapNotNull { parentSymbol -> val annotationScopeClassId = extractScopeClassId(parentSymbol, ContributesRobotIds.CONTRIBUTES_ROBOT_CLASS_ID, session) ?: return@mapNotNull null if (annotationScopeClassId != scopeClassId) return@mapNotNull null val contributionInterfaceClassId = parentSymbol.classId.createNestedClassId(ContributesRobotIds.NESTED_INTERFACE_NAME) val contributionSymbol = session.symbolProvider.getClassLikeSymbolByClassId(contributionInterfaceClassId) as? FirRegularClassSymbol ?: return@mapNotNull null val scope = contributionSymbol.declaredMemberScope(session, memberRequiredPhase = null) val metroContributionName = scope.getClassifierNames().firstOrNull { it.identifier.startsWith("MetroContributionTo") } ?: return@mapNotNull null val metroContributionSymbol = scope.getSingleClassifier(metroContributionName) as? FirRegularClassSymbol ?: return@mapNotNull null MetroContributionExtension.Contribution( supertype = metroContributionSymbol.defaultType(), replaces = emptyList(), originClassId = parentSymbol.classId, ) } } @AutoService(MetroContributionExtension.Factory::class) public class Factory : MetroContributionExtension.Factory { override fun create( session: FirSession, options: MetroOptions, compatContext: CompatContext, ): MetroContributionExtension { return ContributesRobotMetroExtension(session) } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/scoped/ContributesScopedChecker.kt ================================================ package software.amazon.app.platform.metro.compiler.scoped import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.diagnostics.DiagnosticReporter import org.jetbrains.kotlin.diagnostics.reportOn import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirClassChecker import org.jetbrains.kotlin.fir.declarations.FirClass import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.fir.AppPlatformMetroExtensionsDiagnostics import software.amazon.app.platform.metro.compiler.fir.hasAnnotation internal object ContributesScopedChecker : FirClassChecker(MppCheckerKind.Common) { context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(declaration: FirClass) { declaration.source ?: return val session = context.session val classSymbol = declaration.symbol as? FirRegularClassSymbol ?: return val contributesScopedAnnotation = declaration.annotations.firstOrNull { candidate -> candidate.toAnnotationClassId(session) == ContributesScopedIds.CONTRIBUTES_SCOPED_CLASS_ID } if (contributesScopedAnnotation != null) { if (declaration.classKind != ClassKind.CLASS) { reporter.reportOn( contributesScopedAnnotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_SCOPED_ERROR, "@ContributesScoped can only be applied to classes, not " + "${declaration.classKind.name.lowercase().replace('_', ' ')}s.", ) return } if (!hasAnnotation(classSymbol, ClassIds.INJECT, session)) { reporter.reportOn( contributesScopedAnnotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_SCOPED_ERROR, "${classSymbol.name.asString()} must be annotated with @Inject when using " + "@ContributesScoped.", ) return } if (!implementsScoped(classSymbol, session)) { reporter.reportOn( contributesScopedAnnotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_SCOPED_ERROR, "In order to use @ContributesScoped, ${classSymbol.name.asString()} must implement " + "${ClassIds.SCOPED.asSingleFqName()}.", ) return } if (directOtherSupertypes(classSymbol, session).size > 1) { reporter.reportOn( contributesScopedAnnotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_SCOPED_ERROR, "In order to use @ContributesScoped, ${classSymbol.name.asString()} is allowed to " + "have only one other super type besides Scoped.", ) return } } val contributesBindingAnnotation = declaration.annotations.firstOrNull { candidate -> candidate.toAnnotationClassId(session) == ClassIds.CONTRIBUTES_BINDING } ?: return if (implementsScoped(classSymbol, session)) { reporter.reportOn( contributesBindingAnnotation.source ?: declaration.source, AppPlatformMetroExtensionsDiagnostics.CONTRIBUTES_SCOPED_ERROR, "${classSymbol.name.asString()} implements Scoped, but uses @ContributesBinding " + "instead of @ContributesScoped. When implementing Scoped the annotation " + "@ContributesScoped must be used instead of @ContributesBinding to bind both super " + "types correctly. It's not necessary to use @ContributesBinding.", ) } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/scoped/ContributesScopedFir.kt ================================================ package software.amazon.app.platform.metro.compiler.scoped import com.google.auto.service.AutoService import dev.zacsweers.metro.compiler.MetroOptions import dev.zacsweers.metro.compiler.api.fir.MetroFirDeclarationGenerationExtension import dev.zacsweers.metro.compiler.compat.CompatContext import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.descriptors.Modality import org.jetbrains.kotlin.descriptors.Visibilities import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.FirFunction import org.jetbrains.kotlin.fir.declarations.FirResolvePhase import org.jetbrains.kotlin.fir.declarations.builder.buildNamedFunction import org.jetbrains.kotlin.fir.declarations.builder.buildRegularClass import org.jetbrains.kotlin.fir.declarations.builder.buildValueParameter import org.jetbrains.kotlin.fir.declarations.impl.FirResolvedDeclarationStatusImpl import org.jetbrains.kotlin.fir.declarations.origin import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.NestedClassGenerationContext import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.moduleData import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.scopes.kotlinScopeProvider import org.jetbrains.kotlin.fir.symbols.impl.ConeClassLikeLookupTagImpl import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirNamedFunctionSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol import org.jetbrains.kotlin.fir.toEffectiveVisibility import org.jetbrains.kotlin.fir.toFirResolvedTypeRef import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.impl.ConeClassLikeTypeImpl import org.jetbrains.kotlin.name.CallableId import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.Keys import software.amazon.app.platform.metro.compiler.fir.buildAnnotationCallWithArgument import software.amazon.app.platform.metro.compiler.fir.buildClassExpression import software.amazon.app.platform.metro.compiler.fir.buildSimpleAnnotationCall import software.amazon.app.platform.metro.compiler.fir.extractScopeArgument import software.amazon.app.platform.metro.compiler.fir.extractScopeClassId import software.amazon.app.platform.metro.compiler.fir.hasAnnotation /** * Generates the declaration shape for `@ContributesScoped` classes. * * Pseudo Kotlin: * ```kotlin * @Inject * @ContributesScoped(AppScope::class) * class TestClass : SuperType, Scoped { * * @ContributesTo(AppScope::class) * @Origin(TestClass::class) * interface ScopedContribution { * @Binds * fun bindSuperType(instance: TestClass): SuperType * * @Binds * @IntoSet * @ForScope(AppScope::class) * fun bindTestClassScoped(instance: TestClass): Scoped * } * } * ``` * * No top-level graph interface is generated. If the contributed class only implements `Scoped`, * then `bindSuperType()` is omitted and only the scoped multibinding is generated. */ public class ContributesScopedFir(session: FirSession) : MetroFirDeclarationGenerationExtension(session) { override fun FirDeclarationPredicateRegistrar.registerPredicates() { register(ContributesScopedIds.PREDICATE) } override fun getContributionHints(): List { return annotatedScopedClasses().mapNotNull { classSymbol -> if (contributesScopedMetadata(classSymbol, session) == null) return@mapNotNull null val scopeClassId = extractScopeClassId(classSymbol, ContributesScopedIds.CONTRIBUTES_SCOPED_CLASS_ID, session) ?: return@mapNotNull null ContributionHint( contributingClassId = classSymbol.classId.createNestedClassId(ContributesScopedIds.NESTED_INTERFACE_NAME), scope = scopeClassId, ) } } override fun getNestedClassifiersNames( classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext, ): Set { val regularClass = classSymbol as? FirRegularClassSymbol ?: return emptySet() return if ( hasAnnotation(classSymbol, ContributesScopedIds.CONTRIBUTES_SCOPED_CLASS_ID, session) && contributesScopedMetadata(regularClass, session) != null ) { setOf(ContributesScopedIds.NESTED_INTERFACE_NAME) } else { emptySet() } } override fun generateNestedClassLikeDeclaration( owner: FirClassSymbol<*>, name: Name, context: NestedClassGenerationContext, ): FirClassLikeSymbol<*>? { if (name != ContributesScopedIds.NESTED_INTERFACE_NAME) return null if (!hasAnnotation(owner, ContributesScopedIds.CONTRIBUTES_SCOPED_CLASS_ID, session)) { return null } val scopedOwner = owner as? FirRegularClassSymbol ?: return null val metadata = contributesScopedMetadata(scopedOwner, session) ?: return null val scopeArg = extractScopeArgument(scopedOwner, ContributesScopedIds.CONTRIBUTES_SCOPED_CLASS_ID, session) ?: return null val nestedClassId = scopedOwner.classId.createNestedClassId(name) val contributionSymbol = FirRegularClassSymbol(nestedClassId) buildRegularClass { resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesScopedGeneratorKey.origin source = scopedOwner.source classKind = ClassKind.INTERFACE scopeProvider = session.kotlinScopeProvider this.name = nestedClassId.shortClassName symbol = contributionSymbol status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.ABSTRACT, Visibilities.Public.toEffectiveVisibility(scopedOwner, forClass = true), ) superTypeRefs += session.builtinTypes.anyType annotations += buildAnnotationCallWithArgument( classId = ClassIds.CONTRIBUTES_TO, argName = Name.identifier("scope"), argument = scopeArg, containingSymbol = contributionSymbol, session = session, ) annotations += buildAnnotationCallWithArgument( classId = ClassIds.ORIGIN, argName = Name.identifier("value"), argument = buildClassExpression(scopedOwner, session), containingSymbol = contributionSymbol, session = session, ) buildBindingFunctions(nestedClassId, scopedOwner, metadata, scopeArg).forEach { function -> declarations += function } } return contributionSymbol } private fun annotatedScopedClasses(): List { return session.predicateBasedProvider .getSymbolsByPredicate(ContributesScopedIds.PREDICATE) .filterIsInstance() .toList() } private fun buildBindingFunctions( contributionClassId: ClassId, owner: FirRegularClassSymbol, metadata: ScopedContributionMetadata, scopeArg: org.jetbrains.kotlin.fir.expressions.FirExpression, ): List { return buildList { metadata.otherSuperType?.let { otherSuperType -> add(buildBindFunction(contributionClassId, owner, otherSuperType)) } add(buildBindScopedFunction(contributionClassId, owner, scopeArg)) } } private fun buildBindFunction( contributionClassId: ClassId, owner: FirRegularClassSymbol, otherSuperType: ResolvedScopedSuperType, ): FirFunction { val functionName = "bind${ContributesScopedIds.generatedTypeName(otherSuperType.classId)}" return buildBindFunction( contributionClassId = contributionClassId, owner = owner, functionName = functionName, returnType = otherSuperType.coneType, ) } private fun buildBindScopedFunction( contributionClassId: ClassId, owner: FirRegularClassSymbol, scopeArg: org.jetbrains.kotlin.fir.expressions.FirExpression, ): FirFunction { val functionName = "bind${ContributesScopedIds.generatedOwnerName(owner.classId)}Scoped" return buildBindFunction( contributionClassId = contributionClassId, owner = owner, functionName = functionName, returnType = scopedType(), additionalAnnotations = { functionSymbol -> add(buildSimpleAnnotationCall(ClassIds.INTO_SET, functionSymbol, session)) add( buildAnnotationCallWithArgument( classId = ClassIds.FOR_SCOPE, argName = Name.identifier("scope"), argument = scopeArg, containingSymbol = functionSymbol, session = session, ) ) }, ) } private fun buildBindFunction( contributionClassId: ClassId, owner: FirRegularClassSymbol, functionName: String, returnType: ConeKotlinType, additionalAnnotations: MutableList.( FirNamedFunctionSymbol ) -> Unit = {}, ): FirFunction { val callableId = CallableId(contributionClassId, Name.identifier(functionName)) val functionSymbol = FirNamedFunctionSymbol(callableId) return buildNamedFunction { isLocal = false resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesScopedGeneratorKey.origin source = owner.source symbol = functionSymbol name = callableId.callableName returnTypeRef = returnType.toFirResolvedTypeRef() dispatchReceiverType = contributionType(contributionClassId) status = FirResolvedDeclarationStatusImpl( Visibilities.Public, Modality.OPEN, Visibilities.Public.toEffectiveVisibility(owner, forClass = true), ) valueParameters += buildValueParameter { resolvePhase = FirResolvePhase.BODY_RESOLVE moduleData = session.moduleData origin = Keys.ContributesScopedGeneratorKey.origin source = owner.source returnTypeRef = owner.defaultType().toFirResolvedTypeRef() name = Name.identifier("instance") symbol = FirValueParameterSymbol() containingDeclarationSymbol = functionSymbol } annotations += buildSimpleAnnotationCall(ClassIds.BINDS, functionSymbol, session) annotations.additionalAnnotations(functionSymbol) } } private fun contributionType(classId: ClassId) = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(classId), emptyArray(), isMarkedNullable = false, ) private fun scopedType() = ConeClassLikeTypeImpl( ConeClassLikeLookupTagImpl(ClassIds.SCOPED), emptyArray(), isMarkedNullable = false, ) @AutoService(MetroFirDeclarationGenerationExtension.Factory::class) public class Factory : MetroFirDeclarationGenerationExtension.Factory { override fun create( session: FirSession, options: MetroOptions, compatContext: CompatContext, ): MetroFirDeclarationGenerationExtension = ContributesScopedFir(session) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/scoped/ContributesScopedIds.kt ================================================ package software.amazon.app.platform.metro.compiler.scoped import org.jetbrains.kotlin.fir.extensions.predicate.LookupPredicate import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.Name import software.amazon.app.platform.metro.compiler.ClassIds internal object ContributesScopedIds { val CONTRIBUTES_SCOPED_CLASS_ID = ClassIds.CONTRIBUTES_SCOPED val NESTED_INTERFACE_NAME: Name = Name.identifier("ScopedContribution") val PREDICATE = LookupPredicate.create { annotated(CONTRIBUTES_SCOPED_CLASS_ID.asSingleFqName()) } fun generatedOwnerName(contributingClassId: ClassId): String { return contributingClassId.relativeClassName.pathSegments().joinToString(separator = "") { it.asString() } } fun generatedTypeName(boundClassId: ClassId): String { return boundClassId.relativeClassName.pathSegments().joinToString(separator = "") { it.asString() } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/scoped/ContributesScopedMetroExtension.kt ================================================ package software.amazon.app.platform.metro.compiler.scoped import com.google.auto.service.AutoService import dev.zacsweers.metro.compiler.MetroOptions import dev.zacsweers.metro.compiler.api.fir.MetroContributionExtension import dev.zacsweers.metro.compiler.compat.CompatContext import dev.zacsweers.metro.compiler.fir.MetroFirTypeResolver import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.scopes.getSingleClassifier import org.jetbrains.kotlin.fir.scopes.impl.declaredMemberScope import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.name.ClassId import software.amazon.app.platform.metro.compiler.fir.extractScopeClassId public class ContributesScopedMetroExtension(private val session: FirSession) : MetroContributionExtension { private val predicate = ContributesScopedIds.PREDICATE private val annotatedClasses by lazy { session.predicateBasedProvider .getSymbolsByPredicate(predicate) .filterIsInstance() .toList() } override fun FirDeclarationPredicateRegistrar.registerPredicates() { register(predicate) } override fun getContributions( scopeClassId: ClassId, typeResolverFactory: MetroFirTypeResolver.Factory, ): List { return annotatedClasses.mapNotNull { parentSymbol -> if (contributesScopedMetadata(parentSymbol, session) == null) return@mapNotNull null val annotationScopeClassId = extractScopeClassId(parentSymbol, ContributesScopedIds.CONTRIBUTES_SCOPED_CLASS_ID, session) ?: return@mapNotNull null if (annotationScopeClassId != scopeClassId) return@mapNotNull null val contributionInterfaceClassId = parentSymbol.classId.createNestedClassId(ContributesScopedIds.NESTED_INTERFACE_NAME) val contributionSymbol = session.symbolProvider.getClassLikeSymbolByClassId(contributionInterfaceClassId) as? FirRegularClassSymbol ?: return@mapNotNull null val scope = contributionSymbol.declaredMemberScope(session, memberRequiredPhase = null) val metroContributionName = scope.getClassifierNames().firstOrNull { it.identifier.startsWith("MetroContributionTo") } ?: return@mapNotNull null val metroContributionSymbol = scope.getSingleClassifier(metroContributionName) as? FirRegularClassSymbol ?: return@mapNotNull null MetroContributionExtension.Contribution( supertype = metroContributionSymbol.defaultType(), replaces = emptyList(), originClassId = parentSymbol.classId, ) } } @AutoService(MetroContributionExtension.Factory::class) public class Factory : MetroContributionExtension.Factory { override fun create( session: FirSession, options: MetroOptions, compatContext: CompatContext, ): MetroContributionExtension { return ContributesScopedMetroExtension(session) } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/main/kotlin/software/amazon/app/platform/metro/compiler/scoped/ContributesScopedSupport.kt ================================================ package software.amazon.app.platform.metro.compiler.scoped import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.resolve.defaultType import org.jetbrains.kotlin.fir.resolve.fullyExpandedType import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.classId import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.StandardClassIds import software.amazon.app.platform.metro.compiler.ClassIds import software.amazon.app.platform.metro.compiler.fir.resolveDeclaredSuperTypes internal data class ResolvedScopedSuperType(val classId: ClassId, val coneType: ConeKotlinType) internal data class ScopedContributionMetadata(val otherSuperType: ResolvedScopedSuperType?) internal fun contributesScopedMetadata( classSymbol: FirRegularClassSymbol, session: FirSession, ): ScopedContributionMetadata? { if (!implementsScoped(classSymbol, session)) return null val otherSuperTypes = directOtherSupertypes(classSymbol, session) if (otherSuperTypes.size > 1) return null return ScopedContributionMetadata(otherSuperType = otherSuperTypes.singleOrNull()) } internal fun directOtherSupertypes( classSymbol: FirRegularClassSymbol, session: FirSession, ): List { return resolveDeclaredSuperTypes(classSymbol, session).mapNotNull { superType -> val classId = superType.classId ?: return@mapNotNull null if (classId == ClassIds.SCOPED || classId == StandardClassIds.Any) return@mapNotNull null ResolvedScopedSuperType(classId = classId, coneType = superType) } } internal fun implementsScoped(classSymbol: FirRegularClassSymbol, session: FirSession): Boolean { return isScopedType(classSymbol.defaultType(), session) } private fun isScopedType( type: ConeKotlinType, session: FirSession, visited: MutableSet = mutableSetOf(), ): Boolean { val expandedType = type.fullyExpandedType(session) if (!visited.add(expandedType)) return false if (expandedType.classId == ClassIds.SCOPED) return true val classSymbol = expandedType.toRegularClassSymbol(session) ?: return false return resolveDeclaredSuperTypes(classSymbol, session, actualType = expandedType).any { isScopedType(it, session, visited) } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/java/software/amazon/app/platform/metro/compiler/runners/BoxTestGenerated.java ================================================ package software.amazon.app.platform.metro.compiler.runners; import com.intellij.testFramework.TestDataPath; import org.jetbrains.kotlin.test.util.KtTestUtil; import org.jetbrains.kotlin.test.TestMetadata; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.io.File; import java.util.regex.Pattern; /** This class is generated by {@link software.amazon.app.platform.metro.compiler.GenerateTestsKt}. DO NOT MODIFY MANUALLY */ @SuppressWarnings("all") @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box") @TestDataPath("$PROJECT_ROOT") public class BoxTestGenerated extends AbstractBoxTest { @Test public void testAllFilesPresentInBox() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer") @TestDataPath("$PROJECT_ROOT") public class Contributesrenderer { @Test public void testAllFilesPresentInContributesrenderer() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("defaultConstructorRenderer.kt") public void testDefaultConstructorRenderer() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/defaultConstructorRenderer.kt"); } @Test @TestMetadata("explicitModelType.kt") public void testExplicitModelType() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/explicitModelType.kt"); } @Test @TestMetadata("inferredFromHierarchy.kt") public void testInferredFromHierarchy() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/inferredFromHierarchy.kt"); } @Test @TestMetadata("inferredFromHierarchyMultipleLevels.kt") public void testInferredFromHierarchyMultipleLevels() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/inferredFromHierarchyMultipleLevels.kt"); } @Test @TestMetadata("injectConstructorRenderer.kt") public void testInjectConstructorRenderer() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/injectConstructorRenderer.kt"); } @Test @TestMetadata("innerModel.kt") public void testInnerModel() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/innerModel.kt"); } @Test @TestMetadata("innerRenderer.kt") public void testInnerRenderer() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/innerRenderer.kt"); } @Test @TestMetadata("sealedHierarchy.kt") public void testSealedHierarchy() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/sealedHierarchy.kt"); } @Test @TestMetadata("sealedHierarchyDisabled.kt") public void testSealedHierarchyDisabled() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/sealedHierarchyDisabled.kt"); } @Test @TestMetadata("sealedHierarchyInDependencyModule.kt") public void testSealedHierarchyInDependencyModule() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/sealedHierarchyInDependencyModule.kt"); } } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrobot") @TestDataPath("$PROJECT_ROOT") public class Contributesrobot { @Test public void testAllFilesPresentInContributesrobot() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrobot"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("defaultConstructorRobot.kt") public void testDefaultConstructorRobot() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrobot/defaultConstructorRobot.kt"); } @Test @TestMetadata("indirectSupertype.kt") public void testIndirectSupertype() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrobot/indirectSupertype.kt"); } @Test @TestMetadata("injectConstructorRobot.kt") public void testInjectConstructorRobot() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrobot/injectConstructorRobot.kt"); } } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesscoped") @TestDataPath("$PROJECT_ROOT") public class Contributesscoped { @Test public void testAllFilesPresentInContributesscoped() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesscoped"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("defaultScoped.kt") public void testDefaultScoped() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesscoped/defaultScoped.kt"); } @Test @TestMetadata("innerClass.kt") public void testInnerClass() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesscoped/innerClass.kt"); } @Test @TestMetadata("onlyScoped.kt") public void testOnlyScoped() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesscoped/onlyScoped.kt"); } @Test @TestMetadata("transitiveScoped.kt") public void testTransitiveScoped() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesscoped/transitiveScoped.kt"); } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/java/software/amazon/app/platform/metro/compiler/runners/FirDiagnosticTestGenerated.java ================================================ package software.amazon.app.platform.metro.compiler.runners; import com.intellij.testFramework.TestDataPath; import org.jetbrains.kotlin.test.util.KtTestUtil; import org.jetbrains.kotlin.test.TestMetadata; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.io.File; import java.util.regex.Pattern; /** This class is generated by {@link software.amazon.app.platform.metro.compiler.GenerateTestsKt}. DO NOT MODIFY MANUALLY */ @SuppressWarnings("all") @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics") @TestDataPath("$PROJECT_ROOT") public class FirDiagnosticTestGenerated extends AbstractFirDiagnosticTest { @Test public void testAllFilesPresentInDiagnostics() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrenderer") @TestDataPath("$PROJECT_ROOT") public class Contributesrenderer { @Test public void testAllFilesPresentInContributesrenderer() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrenderer"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("missingInjectOnNonZeroArgConstructor.kt") public void testMissingInjectOnNonZeroArgConstructor() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrenderer/missingInjectOnNonZeroArgConstructor.kt"); } @Test @TestMetadata("modelTypeMustBeExplicitWhenNotInferable.kt") public void testModelTypeMustBeExplicitWhenNotInferable() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrenderer/modelTypeMustBeExplicitWhenNotInferable.kt"); } @Test @TestMetadata("redundantInjectOnZeroArgConstructor.kt") public void testRedundantInjectOnZeroArgConstructor() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrenderer/redundantInjectOnZeroArgConstructor.kt"); } @Test @TestMetadata("rendererMustNotBeSingleton.kt") public void testRendererMustNotBeSingleton() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrenderer/rendererMustNotBeSingleton.kt"); } } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrobot") @TestDataPath("$PROJECT_ROOT") public class Contributesrobot { @Test public void testAllFilesPresentInContributesrobot() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrobot"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("classMustImplementRobot.kt") public void testClassMustImplementRobot() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrobot/classMustImplementRobot.kt"); } @Test @TestMetadata("classWithConstructorParametersMustUseInject.kt") public void testClassWithConstructorParametersMustUseInject() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrobot/classWithConstructorParametersMustUseInject.kt"); } @Test @TestMetadata("onlyAppScopeSupported.kt") public void testOnlyAppScopeSupported() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrobot/onlyAppScopeSupported.kt"); } @Test @TestMetadata("robotMustNotBeSingleton.kt") public void testRobotMustNotBeSingleton() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesrobot/robotMustNotBeSingleton.kt"); } } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped") @TestDataPath("$PROJECT_ROOT") public class Contributesscoped { @Test public void testAllFilesPresentInContributesscoped() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("multipleOtherSupertypes.kt") public void testMultipleOtherSupertypes() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped/multipleOtherSupertypes.kt"); } @Test @TestMetadata("mustBeInject.kt") public void testMustBeInject() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped/mustBeInject.kt"); } @Test @TestMetadata("mustImplementScoped.kt") public void testMustImplementScoped() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped/mustImplementScoped.kt"); } @Test @TestMetadata("noSupertypes.kt") public void testNoSupertypes() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped/noSupertypes.kt"); } @Test @TestMetadata("useContributesScopedInsteadOfContributesBinding.kt") public void testUseContributesScopedInsteadOfContributesBinding() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/diagnostics/contributesscoped/useContributesScopedInsteadOfContributesBinding.kt"); } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/java/software/amazon/app/platform/metro/compiler/runners/FirDumpTestGenerated.java ================================================ package software.amazon.app.platform.metro.compiler.runners; import com.intellij.testFramework.TestDataPath; import org.jetbrains.kotlin.test.util.KtTestUtil; import org.jetbrains.kotlin.test.TestMetadata; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.io.File; import java.util.regex.Pattern; /** This class is generated by {@link software.amazon.app.platform.metro.compiler.GenerateTestsKt}. DO NOT MODIFY MANUALLY */ @SuppressWarnings("all") @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump") @TestDataPath("$PROJECT_ROOT") public class FirDumpTestGenerated extends AbstractFirDumpTest { @Test public void testAllFilesPresentInDump() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrenderer") @TestDataPath("$PROJECT_ROOT") public class Contributesrenderer { @Test public void testAllFilesPresentInContributesrenderer() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrenderer"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("defaultConstructorRenderer.kt") public void testDefaultConstructorRenderer() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrenderer/defaultConstructorRenderer.kt"); } @Test @TestMetadata("defaultConstructorRendererIr.kt") public void testDefaultConstructorRendererIr() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrenderer/defaultConstructorRendererIr.kt"); } } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrobot") @TestDataPath("$PROJECT_ROOT") public class Contributesrobot { @Test public void testAllFilesPresentInContributesrobot() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrobot"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("defaultConstructorRobot.kt") public void testDefaultConstructorRobot() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrobot/defaultConstructorRobot.kt"); } @Test @TestMetadata("defaultConstructorRobotIr.kt") public void testDefaultConstructorRobotIr() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesrobot/defaultConstructorRobotIr.kt"); } } @Nested @TestMetadata("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesscoped") @TestDataPath("$PROJECT_ROOT") public class Contributesscoped { @Test public void testAllFilesPresentInContributesscoped() { KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesscoped"), Pattern.compile("^(.+)\\.kt$"), null, true); } @Test @TestMetadata("defaultScoped.kt") public void testDefaultScoped() { runTest("metro-extensions/contribute/impl-compiler-plugin/src/test/resources/dump/contributesscoped/defaultScoped.kt"); } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/GenerateTests.kt ================================================ package software.amazon.app.platform.metro.compiler import org.jetbrains.kotlin.generators.dsl.junit5.generateTestGroupSuiteWithJUnit5 import software.amazon.app.platform.metro.compiler.runners.AbstractBoxTest import software.amazon.app.platform.metro.compiler.runners.AbstractFirDiagnosticTest import software.amazon.app.platform.metro.compiler.runners.AbstractFirDumpTest fun main() { generateTestGroupSuiteWithJUnit5 { testGroup( testDataRoot = "metro-extensions/contribute/impl-compiler-plugin/src/test/resources", testsRoot = "metro-extensions/contribute/impl-compiler-plugin/src/test/java", ) { testClass { model("box") } testClass { model("diagnostics") } testClass { model("dump") } } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/runners/AbstractBoxTest.kt ================================================ package software.amazon.app.platform.metro.compiler.runners import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.test.FirParser import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.directives.CodegenTestDirectives import org.jetbrains.kotlin.test.directives.ConfigurationDirectives import org.jetbrains.kotlin.test.directives.JvmEnvironmentConfigurationDirectives import org.jetbrains.kotlin.test.runners.codegen.AbstractFirBlackBoxCodegenTestBase import org.jetbrains.kotlin.test.services.EnvironmentBasedStandardLibrariesPathProvider import org.jetbrains.kotlin.test.services.KotlinStandardLibrariesPathProvider import software.amazon.app.platform.metro.compiler.services.configureKotlinTestImports import software.amazon.app.platform.metro.compiler.services.configureMetroImports import software.amazon.app.platform.metro.compiler.services.configurePlugin import software.amazon.app.platform.metro.compiler.services.configureTestSupportClasspath open class AbstractBoxTest : AbstractFirBlackBoxCodegenTestBase(FirParser.LightTree) { override fun createKotlinStandardLibrariesPathProvider(): KotlinStandardLibrariesPathProvider { return EnvironmentBasedStandardLibrariesPathProvider } override fun configure(builder: TestConfigurationBuilder) = with(builder) { super.configure(this) defaultDirectives { JvmEnvironmentConfigurationDirectives.JVM_TARGET.with(JvmTarget.JVM_11) +ConfigurationDirectives.WITH_STDLIB +JvmEnvironmentConfigurationDirectives.FULL_JDK +CodegenTestDirectives.IGNORE_DEXING } configurePlugin() configureTestSupportClasspath() configureMetroImports() configureKotlinTestImports() } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/runners/AbstractFirDiagnosticTest.kt ================================================ package software.amazon.app.platform.metro.compiler.runners import org.jetbrains.kotlin.test.FirParser import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.directives.CodegenTestDirectives import org.jetbrains.kotlin.test.directives.FirDiagnosticsDirectives import org.jetbrains.kotlin.test.directives.JvmEnvironmentConfigurationDirectives import org.jetbrains.kotlin.test.directives.TestPhaseDirectives import org.jetbrains.kotlin.test.runners.AbstractFirPhasedDiagnosticTest import org.jetbrains.kotlin.test.services.EnvironmentBasedStandardLibrariesPathProvider import org.jetbrains.kotlin.test.services.KotlinStandardLibrariesPathProvider import org.jetbrains.kotlin.test.services.TestPhase import software.amazon.app.platform.metro.compiler.services.configureMetroImports import software.amazon.app.platform.metro.compiler.services.configurePlugin open class AbstractFirDiagnosticTest : AbstractFirPhasedDiagnosticTest(FirParser.LightTree) { override fun createKotlinStandardLibrariesPathProvider(): KotlinStandardLibrariesPathProvider { return EnvironmentBasedStandardLibrariesPathProvider } override fun configure(builder: TestConfigurationBuilder) = with(builder) { super.configure(builder) defaultDirectives { +FirDiagnosticsDirectives.DISABLE_GENERATED_FIR_TAGS +JvmEnvironmentConfigurationDirectives.FULL_JDK +CodegenTestDirectives.IGNORE_DEXING TestPhaseDirectives.RUN_PIPELINE_TILL.with(TestPhase.FRONTEND) } configurePlugin() configureMetroImports() } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/runners/AbstractFirDumpTest.kt ================================================ package software.amazon.app.platform.metro.compiler.runners import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.directives.CodegenTestDirectives import org.jetbrains.kotlin.test.directives.ConfigurationDirectives import org.jetbrains.kotlin.test.directives.FirDiagnosticsDirectives import org.jetbrains.kotlin.test.directives.JvmEnvironmentConfigurationDirectives import org.jetbrains.kotlin.test.runners.ir.AbstractFirLightTreeJvmIrTextTest import org.jetbrains.kotlin.test.services.EnvironmentBasedStandardLibrariesPathProvider import org.jetbrains.kotlin.test.services.KotlinStandardLibrariesPathProvider import software.amazon.app.platform.metro.compiler.services.configureMetroImports import software.amazon.app.platform.metro.compiler.services.configurePlugin open class AbstractFirDumpTest : AbstractFirLightTreeJvmIrTextTest() { override fun createKotlinStandardLibrariesPathProvider(): KotlinStandardLibrariesPathProvider { return EnvironmentBasedStandardLibrariesPathProvider } override fun configure(builder: TestConfigurationBuilder) { super.configure(builder) with(builder) { configurePlugin() configureMetroImports() defaultDirectives { JvmEnvironmentConfigurationDirectives.JVM_TARGET.with(JvmTarget.JVM_11) +ConfigurationDirectives.WITH_STDLIB +JvmEnvironmentConfigurationDirectives.FULL_JDK +FirDiagnosticsDirectives.FIR_DUMP +FirDiagnosticsDirectives.DISABLE_GENERATED_FIR_TAGS +CodegenTestDirectives.IGNORE_DEXING -CodegenTestDirectives.DUMP_IR -CodegenTestDirectives.DUMP_KT_IR } } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/services/CompilerPluginTestSupport.kt ================================================ package software.amazon.app.platform.metro.compiler.services import dev.zacsweers.metro.compiler.MetroCompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.model.TestModule import org.jetbrains.kotlin.test.services.EnvironmentConfigurator import org.jetbrains.kotlin.test.services.TestServices import software.amazon.app.platform.metro.compiler.AppPlatformMetroExtensionsPluginComponentRegistrar fun TestConfigurationBuilder.configurePlugin() { useConfigurators(::ExtensionRegistrarConfigurator) configureAnnotations() configureMetroRuntime() } fun TestConfigurationBuilder.configureMetroImports() { useSourcePreprocessor(::MetroImportsPreprocessor) } fun TestConfigurationBuilder.configureKotlinTestImports() { useSourcePreprocessor(::KotlinTestImportsPreprocessor) } private class ExtensionRegistrarConfigurator(testServices: TestServices) : EnvironmentConfigurator(testServices) { private val metroRegistrar = MetroCompilerPluginRegistrar() private val extensionsRegistrar = AppPlatformMetroExtensionsPluginComponentRegistrar() override fun CompilerPluginRegistrar.ExtensionStorage.registerCompilerExtensions( module: TestModule, configuration: CompilerConfiguration, ) { with(metroRegistrar) { registerExtensions(configuration) } with(extensionsRegistrar) { registerExtensions(configuration) } } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/services/KotlinTestImportsPreprocessor.kt ================================================ package software.amazon.app.platform.metro.compiler.services import org.jetbrains.kotlin.test.model.TestFile import org.jetbrains.kotlin.test.services.ReversibleSourceFilePreprocessor import org.jetbrains.kotlin.test.services.TestServices import org.jetbrains.kotlin.test.services.isJavaFile class KotlinTestImportsPreprocessor(testServices: TestServices) : ReversibleSourceFilePreprocessor(testServices) { private val additionalImports: Set = setOf("kotlin.test.*") private val additionalImportsString: String by lazy { additionalImports.sorted().joinToString(separator = "\n") { "import $it" } } override fun process(file: TestFile, content: String): String { if (file.isAdditional) return content if (file.isJavaFile) return content val lines = content.lines().toMutableList() when (val packageIndex = lines.indexOfFirst { it.startsWith("package ") }) { -1 -> when (val nonBlankIndex = lines.indexOfFirst { it.isNotBlank() }) { -1 -> lines.add(0, additionalImportsString) else -> lines.add(nonBlankIndex, additionalImportsString) } else -> lines.add(packageIndex + 1, additionalImportsString) } return lines.joinToString(separator = "\n") } override fun revert(file: TestFile, actualContent: String): String { if (file.isAdditional) return actualContent if (file.isJavaFile) return actualContent return actualContent.replace(additionalImportsString + "\n", "") } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/services/MetroImportsPreprocessor.kt ================================================ package software.amazon.app.platform.metro.compiler.services import org.jetbrains.kotlin.test.model.TestFile import org.jetbrains.kotlin.test.services.ReversibleSourceFilePreprocessor import org.jetbrains.kotlin.test.services.TestServices import org.jetbrains.kotlin.test.services.isJavaFile class MetroImportsPreprocessor(testServices: TestServices) : ReversibleSourceFilePreprocessor(testServices) { private val additionalImports: Set = setOf("dev.zacsweers.metro.*") private val additionalImportsString: String by lazy { additionalImports.sorted().joinToString(separator = "\n") { "import $it" } } override fun process(file: TestFile, content: String): String { if (file.isAdditional) return content if (file.isJavaFile) return content val lines = content.lines().toMutableList() when (val packageIndex = lines.indexOfFirst { it.startsWith("package ") }) { -1 -> when (val nonBlankIndex = lines.indexOfFirst { it.isNotBlank() }) { -1 -> lines.add(0, additionalImportsString) else -> lines.add(nonBlankIndex, additionalImportsString) } else -> lines.add(packageIndex + 1, additionalImportsString) } return lines.joinToString(separator = "\n") } override fun revert(file: TestFile, actualContent: String): String { if (file.isAdditional) return actualContent if (file.isJavaFile) return actualContent return actualContent.replace(additionalImportsString + "\n", "") } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/services/MetroRuntimeProvider.kt ================================================ package software.amazon.app.platform.metro.compiler.services import java.io.File import org.jetbrains.kotlin.cli.jvm.config.addJvmClasspathRoots import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.model.TestModule import org.jetbrains.kotlin.test.services.EnvironmentConfigurator import org.jetbrains.kotlin.test.services.RuntimeClasspathProvider import org.jetbrains.kotlin.test.services.TestServices private val metroRuntimeClasspath: List = System.getProperty("metroRuntime.classpath") ?.split(File.pathSeparator) ?.filter { it.isNotBlank() } ?.map(::File) ?: error("Unable to get a valid classpath from 'metroRuntime.classpath' property") fun TestConfigurationBuilder.configureMetroRuntime() { useConfigurators(::MetroRuntimeEnvironmentConfigurator) useCustomRuntimeClasspathProviders(::MetroRuntimeClasspathProvider) } private class MetroRuntimeEnvironmentConfigurator(testServices: TestServices) : EnvironmentConfigurator(testServices) { override fun configureCompilerConfiguration( configuration: CompilerConfiguration, module: TestModule, ) { configuration.addJvmClasspathRoots(metroRuntimeClasspath) } } private class MetroRuntimeClasspathProvider(testServices: TestServices) : RuntimeClasspathProvider(testServices) { override fun runtimeClassPaths(module: TestModule): List = metroRuntimeClasspath } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/services/PluginAnnotationsProvider.kt ================================================ package software.amazon.app.platform.metro.compiler.services import java.io.File import org.jetbrains.kotlin.cli.jvm.config.addJvmClasspathRoots import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.model.TestModule import org.jetbrains.kotlin.test.services.EnvironmentConfigurator import org.jetbrains.kotlin.test.services.RuntimeClasspathProvider import org.jetbrains.kotlin.test.services.TestServices private val annotationsRuntimeClasspath: List = System.getProperty("annotationsRuntime.classpath") ?.split(File.pathSeparator) ?.filter { it.isNotBlank() } ?.map(::File) ?: error("Unable to get a valid classpath from 'annotationsRuntime.classpath' property") fun TestConfigurationBuilder.configureAnnotations() { useConfigurators(::PluginAnnotationsProvider) useCustomRuntimeClasspathProviders(::PluginAnnotationsClasspathProvider) } private class PluginAnnotationsProvider(testServices: TestServices) : EnvironmentConfigurator(testServices) { override fun configureCompilerConfiguration( configuration: CompilerConfiguration, module: TestModule, ) { configuration.addJvmClasspathRoots(annotationsRuntimeClasspath) } } private class PluginAnnotationsClasspathProvider(testServices: TestServices) : RuntimeClasspathProvider(testServices) { override fun runtimeClassPaths(module: TestModule): List = annotationsRuntimeClasspath } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/services/TestSupportClasspathProvider.kt ================================================ package software.amazon.app.platform.metro.compiler.services import java.io.File import org.jetbrains.kotlin.cli.jvm.config.addJvmClasspathRoots import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder import org.jetbrains.kotlin.test.model.TestModule import org.jetbrains.kotlin.test.services.EnvironmentConfigurator import org.jetbrains.kotlin.test.services.RuntimeClasspathProvider import org.jetbrains.kotlin.test.services.TestServices private val testSupportClasspath: List = System.getProperty("testSupport.classpath") ?.split(File.pathSeparator) ?.filter { it.isNotBlank() } ?.map(::File) ?: error("Unable to get a valid classpath from 'testSupport.classpath' property") fun TestConfigurationBuilder.configureTestSupportClasspath() { useConfigurators(::TestSupportClasspathConfigurator) useCustomRuntimeClasspathProviders(::TestSupportRuntimeClasspathProvider) } private class TestSupportClasspathConfigurator(testServices: TestServices) : EnvironmentConfigurator(testServices) { override fun configureCompilerConfiguration( configuration: CompilerConfiguration, module: TestModule, ) { configuration.addJvmClasspathRoots(testSupportClasspath) } } private class TestSupportRuntimeClasspathProvider(testServices: TestServices) : RuntimeClasspathProvider(testServices) { override fun runtimeClassPaths(module: TestModule): List = testSupportClasspath } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/kotlin/software/amazon/app/platform/metro/compiler/support/UnusedRendererFactory.kt ================================================ package software.amazon.app.platform.metro.compiler.support import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererFactory object UnusedRendererFactory : RendererFactory { override fun createRenderer( modelType: kotlin.reflect.KClass ): Renderer { error("unused") } override fun getRenderer( modelType: kotlin.reflect.KClass, rendererId: Int, ): Renderer { error("unused") } } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/defaultConstructorRenderer.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Model : BaseModel @ContributesRenderer class TestRenderer : Renderer { override fun render(model: Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val rendererProvider = graph.renderers.getValue(Model::class) val renderer = rendererProvider() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer but got $renderer" } if (graph.renderers.keys != setOf(Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/explicitModelType.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Model : BaseModel class Model2 : BaseModel @ContributesRenderer(Model::class) class TestRenderer : Renderer { override fun render(model: Model2) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) if (graph.renderers.keys != setOf(Model::class)) { return "FAIL: explicit model type should win, but got keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/inferredFromHierarchy.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Model : BaseModel interface OtherRenderer : Renderer @ContributesRenderer class TestRenderer : OtherRenderer { override fun render(model: Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val renderer = graph.renderers.getValue(Model::class).invoke() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer but got $renderer" } if (graph.renderers.keys != setOf(Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/inferredFromHierarchyMultipleLevels.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Model : BaseModel interface OtherRenderer : Renderer interface OtherRenderer2 : OtherRenderer interface OtherRenderer3 : OtherRenderer2 interface OtherRenderer4 : OtherRenderer3 @ContributesRenderer class TestRenderer : OtherRenderer4 { override fun render(model: Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val renderer = graph.renderers.getValue(Model::class).invoke() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer but got $renderer" } if (graph.renderers.keys != setOf(Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/injectConstructorRenderer.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Model : BaseModel @ContributesRenderer @Inject class TestRenderer(val string: String) : Renderer { override fun render(model: Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph { @Provides fun provideString(): String = "abc" } fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val rendererProvider = graph.renderers.getValue(Model::class) val renderer = rendererProvider() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer but got $renderer" } if (renderer.string != "abc") { return "FAIL: expected injected string to be abc but got ${renderer.string}" } if (graph.renderers.keys != setOf(Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/innerModel.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Presenter { class Model : BaseModel } @ContributesRenderer class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val renderer = graph.renderers.getValue(Presenter.Model::class).invoke() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer but got $renderer" } if (graph.renderers.keys != setOf(Presenter.Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Presenter.Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/innerRenderer.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph class Model : BaseModel class TestRenderer { @ContributesRenderer class Inner : Renderer { override fun render(model: Model) = Unit } } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val renderer = graph.renderers.getValue(Model::class).invoke() if (renderer !is TestRenderer.Inner) { return "FAIL: expected TestRenderer.Inner but got $renderer" } if (graph.renderers.keys != setOf(Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Model::class to TestRenderer.Inner::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/sealedHierarchy.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph interface Presenter { sealed interface Model : BaseModel { sealed interface Inner : Model { data object Model1 : Inner data object Model2 : Inner } data object Model2 : Model class OtherSubclass } } @ContributesRenderer class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val expectedKeys = setOf( Presenter.Model::class, Presenter.Model.Inner::class, Presenter.Model.Inner.Model1::class, Presenter.Model.Inner.Model2::class, Presenter.Model.Model2::class, ) if (graph.renderers.keys != expectedKeys) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping.keys != expectedKeys) { return "FAIL: unexpected modelToRendererMapping keys ${graph.modelToRendererMapping.keys}" } if (graph.modelToRendererMapping.values.toSet() != setOf(TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping values ${graph.modelToRendererMapping.values}" } for (key in expectedKeys) { val renderer = graph.renderers.getValue(key).invoke() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer for $key but got $renderer" } } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/sealedHierarchyDisabled.kt ================================================ package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.metro.compiler.support.UnusedRendererFactory import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererGraph interface Presenter { sealed interface Model : BaseModel { data object Model1 : Model data object Model2 : Model } } @ContributesRenderer(includeSealedSubtypes = false) class TestRenderer : Renderer { override fun render(model: Presenter.Model) = Unit } @DependencyGraph(AppScope::class) interface AppGraph fun box(): String { val factory = createGraph() as RendererGraph.Factory val graph = factory.createRendererGraph(UnusedRendererFactory) val renderer = graph.renderers.getValue(Presenter.Model::class).invoke() if (renderer !is TestRenderer) { return "FAIL: expected TestRenderer but got $renderer" } if (graph.renderers.keys != setOf(Presenter.Model::class)) { return "FAIL: unexpected renderer keys ${graph.renderers.keys}" } if (graph.modelToRendererMapping != mapOf(Presenter.Model::class to TestRenderer::class)) { return "FAIL: unexpected modelToRendererMapping ${graph.modelToRendererMapping}" } return "OK" } ================================================ FILE: metro-extensions/contribute/impl-compiler-plugin/src/test/resources/box/contributesrenderer/sealedHierarchyInDependencyModule.kt ================================================ // MODULE: public // FILE: Template.kt package com.test import software.amazon.app.platform.presenter.BaseModel sealed interface Template : BaseModel { data object FullScreen : Template data object ListDetail : Template } // MODULE: impl(public) // FILE: TestRenderer.kt package com.test import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.renderer.Renderer @ContributesRenderer class TestRenderer : Renderer