The Android logo, released under public domain

How to assemble an Android Program

Those notes are for creating an android program with Java, Kotlin, and C++ in CMake, without Gradle.

Why

The official tool for creating android packages is the build system Gradle.

Unfortunately I really dislike it. I admit that I do not know the build system very well, but in my relatively limited experience with it, I’ve noticed following issues:

  • breaking changes between releases

  • long packaging times

  • leads to mixture of build systems when packaging native code

  • inscrutable error messages

  • breaking changes between releases

  • creating different flavours with Gradle dimension quickly gets out of hand

  • no way to download dependencies before building

  • Gradle might delete downloaded dependencies 🗄️, no way to pin them

  • sharing the cache between concurrent builds (which is the default behaviour) does not work 🗄️

  • unclear which android SDK is used (android studio insists I should use the one of android studio instead of the one configured in the project; why?)

  • unclear which java version is used (since Gradle needs a Java instance for being executed)

  • breaking changes between releases

  • The official IDE (Android Studio) consumes a lot of resources just for opening a project

  • long loading times in Android Studio because of "gradle sync" (without further details). Even if the project can be built immediately from the command line, sometime Android Studio wants to download something

  • it is too easy to pull too many dependencies

  • too many issues on the internet are resolved with "cleaned the project", updating to a newer version, or (what matches my experience) clean the ~/.gradle folder

Granted, not all issues are specific to Gradle (IDE and interaction with other languages), and some issues can surely be attributed to my lack of knowledge.

I guess I should explain further about some points.

An example of issue solved by nuking the ~/.gradle folder:

e: /home/build/.gradle/caches/modules-2/files-2.1/com.squareup.okhttp3/logging-interceptor/5.0.0-alpha.12/39e57bab216d200b965ff60723486d06d89dd6e4/logging-interceptor-5.0.0-alpha.12.jar!/META-INF/logging-interceptor.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1.
e: /home/build/.gradle/caches/modules-2/files-2.1/com.squareup.okhttp3/okhttp-jvm/5.0.0-alpha.12/b54e5430568c6210032f1d03c99cda3db01da3b5/okhttp-jvm-5.0.0-alpha.12.jar!/META-INF/okhttp.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1.
e: /home/build/.gradle/caches/modules-2/files-2.1/com.squareup.okio/okio-jvm/3.7.0/276b999b41f7dcde00054848fc53af338d86b349/okio-jvm-3.7.0.jar!/META-INF/okio.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1.
e: /home/build/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.21/17ee3e873d439566c7d8354403b5f3d9744c4c9c/kotlin-stdlib-1.9.21.jar!/META-INF/kotlin-stdlib.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1.
e: /home/build/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.21/17ee3e873d439566c7d8354403b5f3d9744c4c9c/kotlin-stdlib-1.9.21.jar!/META-INF/kotlin-stdlib-jdk8.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1.
e: /home/build/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.21/17ee3e873d439566c7d8354403b5f3d9744c4c9c/kotlin-stdlib-1.9.21.jar!/META-INF/kotlin-stdlib-jdk7.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1.

Why is Gradle picking a wrong module?

The directory names are unreadable because Gradle hashes all dependencies, this should ensure that multiple dependencies (and multiple versions of the same dependency) can be handled correctly. I seriously doubt that the package kotlin-stdlib managed to create a collision.

An example of error message; the root cause is a typo in a manifest file. But note that the xml file is still "valid", there where no unclosed quotes or something that could confuse a parser; I wrote accidentally applicationa instead of application.

The error message is over 200 lines of text, and only one line of text (the actual useful line) come from the tool used for packaging the application, everything else is just noise:

Show error message
FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':xxx:yyy'.
> A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
   > Android resource linking failed
     ERROR: C:\project\AndroidAppxxx\xxx\src\AndroidManifest.xml:6:2-10:4: AAPT: error: unexpected element <applicationa> found in <manifest>.


* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.

* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':xxx:yyy'.
  at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.lambda$executeIfValid$1(ExecuteActionsTaskExecuter.java:130)
  at org.gradle.internal.Try$Failure.ifSuccessfulOrElse(Try.java:293)
  at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:128)
  at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:116)
  at org.gradle.api.internal.tasks.execution.FinalizePropertiesTaskExecuter.execute(FinalizePropertiesTaskExecuter.java:46)
  at org.gradle.api.internal.tasks.execution.ResolveTaskExecutionModeExecuter.execute(ResolveTaskExecutionModeExecuter.java:51)
  at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:57)
  at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:74)
  at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:36)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.executeTask(EventFiringTaskExecuter.java:77)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:55)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:52)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter.execute(EventFiringTaskExecuter.java:52)
  at org.gradle.execution.plan.LocalTaskNodeExecutor.execute(LocalTaskNodeExecutor.java:42)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:331)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:318)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.lambda$execute$0(DefaultTaskExecutionGraph.java:314)
  at org.gradle.internal.operations.CurrentBuildOperationRef.with(CurrentBuildOperationRef.java:85)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:314)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:303)
  at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.execute(DefaultPlanExecutor.java:459)
  at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.run(DefaultPlanExecutor.java:376)
  at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
  at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
Caused by: org.gradle.workers.internal.DefaultWorkerExecutor$WorkExecutionException: A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
  at org.gradle.workers.internal.DefaultWorkerExecutor$WorkItemExecution.waitForCompletion(DefaultWorkerExecutor.java:287)
  at org.gradle.internal.work.DefaultAsyncWorkTracker.lambda$waitForItemsAndGatherFailures$2(DefaultAsyncWorkTracker.java:130)
  at org.gradle.internal.Factories$1.create(Factories.java:31)
  at org.gradle.internal.work.DefaultWorkerLeaseService.withoutLocks(DefaultWorkerLeaseService.java:339)
  at org.gradle.internal.work.DefaultWorkerLeaseService.withoutLocks(DefaultWorkerLeaseService.java:322)
  at org.gradle.internal.work.DefaultWorkerLeaseService.withoutLock(DefaultWorkerLeaseService.java:327)
  at org.gradle.internal.work.DefaultAsyncWorkTracker.waitForItemsAndGatherFailures(DefaultAsyncWorkTracker.java:126)
  at org.gradle.internal.work.DefaultAsyncWorkTracker.waitForItemsAndGatherFailures(DefaultAsyncWorkTracker.java:92)
  at org.gradle.internal.work.DefaultAsyncWorkTracker.waitForAll(DefaultAsyncWorkTracker.java:78)
  at org.gradle.internal.work.DefaultAsyncWorkTracker.waitForCompletion(DefaultAsyncWorkTracker.java:66)
  at org.gradle.api.internal.tasks.execution.TaskExecution$3.run(TaskExecution.java:252)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
  at org.gradle.api.internal.tasks.execution.TaskExecution.executeAction(TaskExecution.java:229)
  at org.gradle.api.internal.tasks.execution.TaskExecution.executeActions(TaskExecution.java:212)
  at org.gradle.api.internal.tasks.execution.TaskExecution.executeWithPreviousOutputFiles(TaskExecution.java:195)
  at org.gradle.api.internal.tasks.execution.TaskExecution.execute(TaskExecution.java:162)
  at org.gradle.internal.execution.steps.ExecuteStep.executeInternal(ExecuteStep.java:105)
  at org.gradle.internal.execution.steps.ExecuteStep.access$000(ExecuteStep.java:44)
  at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:59)
  at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:56)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
  at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:56)
  at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:44)
  at org.gradle.internal.execution.steps.CancelExecutionStep.execute(CancelExecutionStep.java:42)
  at org.gradle.internal.execution.steps.TimeoutStep.executeWithoutTimeout(TimeoutStep.java:75)
  at org.gradle.internal.execution.steps.TimeoutStep.execute(TimeoutStep.java:55)
  at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:50)
  at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:28)
  at org.gradle.internal.execution.steps.RemovePreviousOutputsStep.execute(RemovePreviousOutputsStep.java:67)
  at org.gradle.internal.execution.steps.RemovePreviousOutputsStep.execute(RemovePreviousOutputsStep.java:37)
  at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:61)
  at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:26)
  at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:69)
  at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:46)
  at org.gradle.internal.execution.steps.ResolveInputChangesStep.execute(ResolveInputChangesStep.java:40)
  at org.gradle.internal.execution.steps.ResolveInputChangesStep.execute(ResolveInputChangesStep.java:29)
  at org.gradle.internal.execution.steps.BuildCacheStep.executeWithoutCache(BuildCacheStep.java:189)
  at org.gradle.internal.execution.steps.BuildCacheStep.lambda$execute$1(BuildCacheStep.java:75)
  at org.gradle.internal.Either$Right.fold(Either.java:175)
  at org.gradle.internal.execution.caching.CachingState.fold(CachingState.java:62)
  at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:73)
  at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:48)
  at org.gradle.internal.execution.steps.StoreExecutionStateStep.execute(StoreExecutionStateStep.java:46)
  at org.gradle.internal.execution.steps.StoreExecutionStateStep.execute(StoreExecutionStateStep.java:35)
  at org.gradle.internal.execution.steps.SkipUpToDateStep.executeBecause(SkipUpToDateStep.java:75)
  at org.gradle.internal.execution.steps.SkipUpToDateStep.lambda$execute$2(SkipUpToDateStep.java:53)
  at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:53)
  at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:35)
  at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:37)
  at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:27)
  at org.gradle.internal.execution.steps.ResolveIncrementalCachingStateStep.executeDelegate(ResolveIncrementalCachingStateStep.java:49)
  at org.gradle.internal.execution.steps.ResolveIncrementalCachingStateStep.executeDelegate(ResolveIncrementalCachingStateStep.java:27)
  at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:71)
  at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:39)
  at org.gradle.internal.execution.steps.ResolveChangesStep.execute(ResolveChangesStep.java:65)
  at org.gradle.internal.execution.steps.ResolveChangesStep.execute(ResolveChangesStep.java:36)
  at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:105)
  at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:54)
  at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:64)
  at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:43)
  at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.executeWithNonEmptySources(AbstractSkipEmptyWorkStep.java:125)
  at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.execute(AbstractSkipEmptyWorkStep.java:56)
  at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.execute(AbstractSkipEmptyWorkStep.java:36)
  at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsStartedStep.execute(MarkSnapshottingInputsStartedStep.java:38)
  at org.gradle.internal.execution.steps.LoadPreviousExecutionStateStep.execute(LoadPreviousExecutionStateStep.java:36)
  at org.gradle.internal.execution.steps.LoadPreviousExecutionStateStep.execute(LoadPreviousExecutionStateStep.java:23)
  at org.gradle.internal.execution.steps.HandleStaleOutputsStep.execute(HandleStaleOutputsStep.java:75)
  at org.gradle.internal.execution.steps.HandleStaleOutputsStep.execute(HandleStaleOutputsStep.java:41)
  at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.lambda$execute$0(AssignMutableWorkspaceStep.java:35)
  at org.gradle.api.internal.tasks.execution.TaskExecution$4.withWorkspace(TaskExecution.java:289)
  at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.execute(AssignMutableWorkspaceStep.java:31)
  at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.execute(AssignMutableWorkspaceStep.java:22)
  at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:40)
  at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:23)
  at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.lambda$execute$2(ExecuteWorkBuildOperationFiringStep.java:67)
  at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:67)
  at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:39)
  at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:46)
  at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:34)
  at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:48)
  at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:35)
  at org.gradle.internal.execution.impl.DefaultExecutionEngine$1.execute(DefaultExecutionEngine.java:61)
  at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:127)
  at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:116)
  at org.gradle.api.internal.tasks.execution.FinalizePropertiesTaskExecuter.execute(FinalizePropertiesTaskExecuter.java:46)
  at org.gradle.api.internal.tasks.execution.ResolveTaskExecutionModeExecuter.execute(ResolveTaskExecutionModeExecuter.java:51)
  at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:57)
  at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:74)
  at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:36)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.executeTask(EventFiringTaskExecuter.java:77)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:55)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:52)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
  at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter.execute(EventFiringTaskExecuter.java:52)
  at org.gradle.execution.plan.LocalTaskNodeExecutor.execute(LocalTaskNodeExecutor.java:42)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:331)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:318)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.lambda$execute$0(DefaultTaskExecutionGraph.java:314)
  at org.gradle.internal.operations.CurrentBuildOperationRef.with(CurrentBuildOperationRef.java:85)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:314)
  at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:303)
  at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.execute(DefaultPlanExecutor.java:459)
  at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.run(DefaultPlanExecutor.java:376)
  at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
  at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
Caused by: com.android.builder.internal.aapt.v2.Aapt2Exception: Android resource linking failed
ERROR: C:\project\AndroidAppxxx\xxx\src\AndroidManifest.xml:6:2-10:4: AAPT: error: unexpected element <applicationa> found in <manifest>.

  at com.android.builder.internal.aapt.v2.Aapt2Exception$Companion.create(Aapt2Exception.kt:45)
  at com.android.builder.internal.aapt.v2.Aapt2Exception$Companion.create$default(Aapt2Exception.kt:33)
  at com.android.build.gradle.internal.res.Aapt2ErrorUtils.rewriteException(Aapt2ErrorUtils.kt:262)
  at com.android.build.gradle.internal.res.Aapt2ErrorUtils.rewriteLinkException(Aapt2ErrorUtils.kt:133)
  at com.android.build.gradle.internal.res.Aapt2ProcessResourcesRunnableKt.processResources(Aapt2ProcessResourcesRunnable.kt:76)
  at com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$Companion.invokeAaptForSplit(LinkApplicationAndroidResourcesTask.kt:945)
  at com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$Companion.access$invokeAaptForSplit(LinkApplicationAndroidResourcesTask.kt:799)
  at com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction.run(LinkApplicationAndroidResourcesTask.kt:432)
  at com.android.build.gradle.internal.profile.ProfileAwareWorkAction.execute(ProfileAwareWorkAction.kt:74)
  at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
  at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
  at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
  at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
  at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
  at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
  at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
  at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
  at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
  at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
  at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
  at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
  at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:195)
  at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:128)
  at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:170)
  at org.gradle.internal.Factories$1.create(Factories.java:31)
  at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:267)
  at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:131)
  at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:136)
  at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:165)
  at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:134)
  ... 2 more

While in this case what is causing the error is clear (ERROR: C:\project\AndroidAppxxx\xxx\src\AndroidManifest.xml:6:2-10:4: AAPT: error: unexpected element <applicationa> found in <manifest>.), one has first to find it. In comparison, linker errors in C++, or errors caused by templates, are a breeze of fresh air!

What about custom errors?

For example, cosndier following snippet in a gradle file

if ( System.getenv('MY_ENV_VAR') == null ) {
  throw new GradleException("Environment variable MY_ENV_VAR is not set, you will not be able to download artifact from the repository")
}

This is the generated output

Details
FAILURE: Build failed with an exception.

* Where:
Script 'D:\project\custom-logic.gradle' line: 16

* What went wrong:
A problem occurred evaluating script.
> Environment variable MY_ENV_VAR is not set, you will not be able to download artifact from the repository

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.

* Exception is:
org.gradle.api.GradleScriptException: A problem occurred evaluating script.
        at org.gradle.groovy.scripts.internal.DefaultScriptRunnerFactory$ScriptRunnerImpl.run(DefaultScriptRunnerFactory.java:93)
        at org.gradle.configuration.DefaultScriptPluginFactory$ScriptPluginImpl.lambda$apply$0(DefaultScriptPluginFactory.java:137)
        at org.gradle.configuration.DefaultScriptTarget.addConfiguration(DefaultScriptTarget.java:74)
        at org.gradle.configuration.DefaultScriptPluginFactory$ScriptPluginImpl.apply(DefaultScriptPluginFactory.java:140)
        at org.gradle.configuration.BuildOperationScriptPlugin$1.run(BuildOperationScriptPlugin.java:68)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
        at org.gradle.configuration.BuildOperationScriptPlugin.lambda$apply$0(BuildOperationScriptPlugin.java:65)
        at org.gradle.internal.code.DefaultUserCodeApplicationContext.apply(DefaultUserCodeApplicationContext.java:44)
        at org.gradle.configuration.BuildOperationScriptPlugin.apply(BuildOperationScriptPlugin.java:65)
        at org.gradle.api.internal.plugins.DefaultObjectConfigurationAction.applyScript(DefaultObjectConfigurationAction.java:150)
        at org.gradle.api.internal.plugins.DefaultObjectConfigurationAction.access$000(DefaultObjectConfigurationAction.java:43)
        at org.gradle.api.internal.plugins.DefaultObjectConfigurationAction$1.run(DefaultObjectConfigurationAction.java:76)
        at org.gradle.api.internal.plugins.DefaultObjectConfigurationAction.execute(DefaultObjectConfigurationAction.java:184)
        at org.gradle.api.internal.project.AbstractPluginAware.apply(AbstractPluginAware.java:49)
        at org.gradle.api.internal.project.ProjectScript.apply(ProjectScript.java:37)
        at org.gradle.api.Script$apply$0.callCurrent(Unknown Source)
        at build_d28ijn3udemktcv0w8t5doyud.run(D:\project\release\build.gradle:216)
        at org.gradle.groovy.scripts.internal.DefaultScriptRunnerFactory$ScriptRunnerImpl.run(DefaultScriptRunnerFactory.java:91)
        at org.gradle.configuration.DefaultScriptPluginFactory$ScriptPluginImpl.lambda$apply$0(DefaultScriptPluginFactory.java:137)
        at org.gradle.configuration.ProjectScriptTarget.addConfiguration(ProjectScriptTarget.java:79)
        at org.gradle.configuration.DefaultScriptPluginFactory$ScriptPluginImpl.apply(DefaultScriptPluginFactory.java:140)
        at org.gradle.configuration.BuildOperationScriptPlugin$1.run(BuildOperationScriptPlugin.java:68)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
        at org.gradle.configuration.BuildOperationScriptPlugin.lambda$apply$0(BuildOperationScriptPlugin.java:65)
        at org.gradle.internal.code.DefaultUserCodeApplicationContext.apply(DefaultUserCodeApplicationContext.java:44)
        at org.gradle.configuration.BuildOperationScriptPlugin.apply(BuildOperationScriptPlugin.java:65)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.lambda$applyToMutableState$1(DefaultProjectStateRegistry.java:407)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.fromMutableState(DefaultProjectStateRegistry.java:425)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.applyToMutableState(DefaultProjectStateRegistry.java:406)
        at org.gradle.configuration.project.BuildScriptProcessor.execute(BuildScriptProcessor.java:46)
        at org.gradle.configuration.project.BuildScriptProcessor.execute(BuildScriptProcessor.java:27)
        at org.gradle.configuration.project.ConfigureActionsProjectEvaluator.evaluate(ConfigureActionsProjectEvaluator.java:35)
        at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.lambda$run$0(LifecycleProjectEvaluator.java:109)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.lambda$applyToMutableState$1(DefaultProjectStateRegistry.java:407)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.lambda$fromMutableState$2(DefaultProjectStateRegistry.java:430)
        at org.gradle.internal.work.DefaultWorkerLeaseService.withReplacedLocks(DefaultWorkerLeaseService.java:363)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.fromMutableState(DefaultProjectStateRegistry.java:430)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.applyToMutableState(DefaultProjectStateRegistry.java:406)
        at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.run(LifecycleProjectEvaluator.java:100)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
        at org.gradle.configuration.project.LifecycleProjectEvaluator.evaluate(LifecycleProjectEvaluator.java:72)
        at org.gradle.api.internal.project.DefaultProject.evaluate(DefaultProject.java:756)
        at org.gradle.api.internal.project.DefaultProject.evaluate(DefaultProject.java:157)
        at org.gradle.api.internal.project.ProjectLifecycleController.lambda$ensureSelfConfigured$2(ProjectLifecycleController.java:84)
        at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
        at org.gradle.internal.model.StateTransitionController.lambda$maybeTransitionIfNotCurrentlyTransitioning$10(StateTransitionController.java:199)
        at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
        at org.gradle.internal.model.StateTransitionController.maybeTransitionIfNotCurrentlyTransitioning(StateTransitionController.java:195)
        at org.gradle.api.internal.project.ProjectLifecycleController.ensureSelfConfigured(ProjectLifecycleController.java:84)
        at org.gradle.api.internal.project.DefaultProjectStateRegistry$ProjectStateImpl.ensureConfigured(DefaultProjectStateRegistry.java:381)
        at org.gradle.execution.TaskPathProjectEvaluator.configure(TaskPathProjectEvaluator.java:34)
        at org.gradle.execution.TaskPathProjectEvaluator.configureHierarchy(TaskPathProjectEvaluator.java:50)
        at org.gradle.configuration.DefaultProjectsPreparer.prepareProjects(DefaultProjectsPreparer.java:42)
        at org.gradle.configuration.BuildTreePreparingProjectsPreparer.prepareProjects(BuildTreePreparingProjectsPreparer.java:65)
        at org.gradle.configuration.BuildOperationFiringProjectsPreparer$ConfigureBuild.run(BuildOperationFiringProjectsPreparer.java:52)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
        at org.gradle.configuration.BuildOperationFiringProjectsPreparer.prepareProjects(BuildOperationFiringProjectsPreparer.java:40)
        at org.gradle.initialization.VintageBuildModelController.lambda$prepareProjects$2(VintageBuildModelController.java:84)
        at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
        at org.gradle.internal.model.StateTransitionController.lambda$transitionIfNotPreviously$11(StateTransitionController.java:213)
        at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
        at org.gradle.internal.model.StateTransitionController.transitionIfNotPreviously(StateTransitionController.java:209)
        at org.gradle.initialization.VintageBuildModelController.prepareProjects(VintageBuildModelController.java:84)
        at org.gradle.initialization.VintageBuildModelController.prepareToScheduleTasks(VintageBuildModelController.java:71)
        at org.gradle.internal.build.DefaultBuildLifecycleController.lambda$prepareToScheduleTasks$6(DefaultBuildLifecycleController.java:175)
        at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
        at org.gradle.internal.model.StateTransitionController.lambda$maybeTransition$9(StateTransitionController.java:190)
        at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
        at org.gradle.internal.model.StateTransitionController.maybeTransition(StateTransitionController.java:186)
        at org.gradle.internal.build.DefaultBuildLifecycleController.prepareToScheduleTasks(DefaultBuildLifecycleController.java:173)
        at org.gradle.internal.buildtree.DefaultBuildTreeWorkPreparer.scheduleRequestedTasks(DefaultBuildTreeWorkPreparer.java:36)
        at org.gradle.internal.cc.impl.VintageBuildTreeWorkController$scheduleAndRunRequestedTasks$1.apply(VintageBuildTreeWorkController.kt:36)
        at org.gradle.internal.cc.impl.VintageBuildTreeWorkController$scheduleAndRunRequestedTasks$1.apply(VintageBuildTreeWorkController.kt:35)
        at org.gradle.composite.internal.DefaultIncludedBuildTaskGraph.withNewWorkGraph(DefaultIncludedBuildTaskGraph.java:112)
        at org.gradle.internal.cc.impl.VintageBuildTreeWorkController.scheduleAndRunRequestedTasks(VintageBuildTreeWorkController.kt:35)
        at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.lambda$scheduleAndRunTasks$1(DefaultBuildTreeLifecycleController.java:77)
        at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.lambda$runBuild$4(DefaultBuildTreeLifecycleController.java:120)
        at org.gradle.internal.model.StateTransitionController.lambda$transition$6(StateTransitionController.java:169)
        at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
        at org.gradle.internal.model.StateTransitionController.lambda$transition$7(StateTransitionController.java:169)
        at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:44)
        at org.gradle.internal.model.StateTransitionController.transition(StateTransitionController.java:169)
        at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.runBuild(DefaultBuildTreeLifecycleController.java:117)
        at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:77)
        at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:72)
        at org.gradle.tooling.internal.provider.ExecuteBuildActionRunner.run(ExecuteBuildActionRunner.java:31)
        at org.gradle.launcher.exec.ChainingBuildActionRunner.run(ChainingBuildActionRunner.java:35)
        at org.gradle.internal.buildtree.ProblemReportingBuildActionRunner.run(ProblemReportingBuildActionRunner.java:49)
        at org.gradle.launcher.exec.BuildOutcomeReportingBuildActionRunner.run(BuildOutcomeReportingBuildActionRunner.java:65)
        at org.gradle.tooling.internal.provider.FileSystemWatchingBuildActionRunner.run(FileSystemWatchingBuildActionRunner.java:140)
        at org.gradle.launcher.exec.BuildCompletionNotifyingBuildActionRunner.run(BuildCompletionNotifyingBuildActionRunner.java:41)
        at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.lambda$execute$0(RootBuildLifecycleBuildActionExecutor.java:40)
        at org.gradle.composite.internal.DefaultRootBuildState.run(DefaultRootBuildState.java:130)
        at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.execute(RootBuildLifecycleBuildActionExecutor.java:40)
        at org.gradle.internal.buildtree.InitDeprecationLoggingActionExecutor.execute(InitDeprecationLoggingActionExecutor.java:62)
        at org.gradle.internal.buildtree.InitProblems.execute(InitProblems.java:36)
        at org.gradle.internal.buildtree.DefaultBuildTreeContext.execute(DefaultBuildTreeContext.java:40)
        at org.gradle.launcher.exec.BuildTreeLifecycleBuildActionExecutor.lambda$execute$0(BuildTreeLifecycleBuildActionExecutor.java:71)
        at org.gradle.internal.buildtree.BuildTreeState.run(BuildTreeState.java:60)
        at org.gradle.launcher.exec.BuildTreeLifecycleBuildActionExecutor.execute(BuildTreeLifecycleBuildActionExecutor.java:71)
        at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor$3.call(RunAsBuildOperationBuildActionExecutor.java:61)
        at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor$3.call(RunAsBuildOperationBuildActionExecutor.java:57)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
        at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
        at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
        at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor.execute(RunAsBuildOperationBuildActionExecutor.java:57)
        at org.gradle.launcher.exec.RunAsWorkerThreadBuildActionExecutor.lambda$execute$0(RunAsWorkerThreadBuildActionExecutor.java:36)
        at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:267)
        at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:131)
        at org.gradle.launcher.exec.RunAsWorkerThreadBuildActionExecutor.execute(RunAsWorkerThreadBuildActionExecutor.java:36)
        at org.gradle.tooling.internal.provider.continuous.ContinuousBuildActionExecutor.execute(ContinuousBuildActionExecutor.java:110)
        at org.gradle.tooling.internal.provider.SubscribableBuildActionExecutor.execute(SubscribableBuildActionExecutor.java:64)
        at org.gradle.internal.session.DefaultBuildSessionContext.execute(DefaultBuildSessionContext.java:46)
        at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor$ActionImpl.apply(BuildSessionLifecycleBuildActionExecutor.java:92)
        at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor$ActionImpl.apply(BuildSessionLifecycleBuildActionExecutor.java:80)
        at org.gradle.internal.session.BuildSessionState.run(BuildSessionState.java:71)
        at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor.execute(BuildSessionLifecycleBuildActionExecutor.java:62)
        at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor.execute(BuildSessionLifecycleBuildActionExecutor.java:41)
        at org.gradle.internal.buildprocess.execution.StartParamsValidatingActionExecutor.execute(StartParamsValidatingActionExecutor.java:64)
        at org.gradle.internal.buildprocess.execution.StartParamsValidatingActionExecutor.execute(StartParamsValidatingActionExecutor.java:32)
        at org.gradle.internal.buildprocess.execution.SessionFailureReportingActionExecutor.execute(SessionFailureReportingActionExecutor.java:51)
        at org.gradle.internal.buildprocess.execution.SessionFailureReportingActionExecutor.execute(SessionFailureReportingActionExecutor.java:39)
        at org.gradle.internal.buildprocess.execution.SetupLoggingActionExecutor.execute(SetupLoggingActionExecutor.java:47)
        at org.gradle.internal.buildprocess.execution.SetupLoggingActionExecutor.execute(SetupLoggingActionExecutor.java:31)
        at org.gradle.launcher.daemon.server.exec.ExecuteBuild.doBuild(ExecuteBuild.java:70)
        at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.WatchForDisconnection.execute(WatchForDisconnection.java:39)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.ResetDeprecationLogger.execute(ResetDeprecationLogger.java:29)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.RequestStopIfSingleUsedDaemon.execute(RequestStopIfSingleUsedDaemon.java:35)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.ForwardClientInput.lambda$execute$0(ForwardClientInput.java:40)
        at org.gradle.internal.daemon.clientinput.ClientInputForwarder.forwardInput(ClientInputForwarder.java:80)
        at org.gradle.launcher.daemon.server.exec.ForwardClientInput.execute(ForwardClientInput.java:37)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.LogAndCheckHealth.execute(LogAndCheckHealth.java:53)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.LogToClient.doBuild(LogToClient.java:63)
        at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.EstablishBuildEnvironment.doBuild(EstablishBuildEnvironment.java:84)
        at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
        at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
        at org.gradle.launcher.daemon.server.exec.StartBuildOrRespondWithBusy$1.run(StartBuildOrRespondWithBusy.java:52)
        at org.gradle.launcher.daemon.server.DaemonStateCoordinator.lambda$runCommand$0(DaemonStateCoordinator.java:320)
        at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
        at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
        Caused by: org.gradle.api.GradleException: Environment variable MY_ENV_VAR is not set, you will not be able to download artifact from the repository
        at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
        at org.gradle.internal.classpath.intercept.DefaultCallSiteDecorator$DecoratingCallSite.access$401(DefaultCallSiteDecorator.java:145)
        at org.gradle.internal.classpath.intercept.DefaultCallSiteDecorator$DecoratingCallSite$4.callOriginal(DefaultCallSiteDecorator.java:199)
        at org.gradle.internal.classpath.intercept.DefaultCallSiteDecorator$1.intercept(DefaultCallSiteDecorator.java:63)
        at org.gradle.internal.classpath.intercept.DefaultCallSiteDecorator$DecoratingCallSite.callConstructor(DefaultCallSiteDecorator.java:196)
        at extensionbuild_24upet4amd11vb72gcdt4o5ix$_run_closure1.doCall$original(D:\project\custom-logic.gradle:16)
        at extensionbuild_24upet4amd11vb72gcdt4o5ix$_run_closure1.doCall(D:\project\custom-logic.gradle)
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
        at org.gradle.util.internal.ClosureBackedAction.execute(ClosureBackedAction.java:73)
        at org.gradle.util.internal.ConfigureUtil.configureTarget(ConfigureUtil.java:166)
        at org.gradle.util.internal.ConfigureUtil.configure(ConfigureUtil.java:107)
        at org.gradle.api.internal.project.DefaultProject.dependencies(DefaultProject.java:1267)
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
        at org.gradle.internal.metaobject.BeanDynamicObject$MetaClassAdapter.invokeMethod(BeanDynamicObject.java:547)
        at org.gradle.internal.metaobject.BeanDynamicObject.tryInvokeMethod(BeanDynamicObject.java:218)
        at org.gradle.internal.metaobject.CompositeDynamicObject.tryInvokeMethod(CompositeDynamicObject.java:99)
        at org.gradle.internal.extensibility.MixInClosurePropertiesAsMethodsDynamicObject.tryInvokeMethod(MixInClosurePropertiesAsMethodsDynamicObject.java:38)
        at org.gradle.groovy.scripts.BasicScript$ScriptDynamicObject.tryInvokeMethod(BasicScript.java:138)
        at org.gradle.internal.metaobject.AbstractDynamicObject.invokeMethod(AbstractDynamicObject.java:163)
        at org.gradle.api.internal.project.DefaultDynamicLookupRoutine.invokeMethod(DefaultDynamicLookupRoutine.java:58)
        at org.gradle.groovy.scripts.BasicScript.invokeMethod(BasicScript.java:87)
        at extensionbuild_24upet4amd11vb72gcdt4o5ix.run(D:\proj\w1\source\projects\release\custom-logic.gradle:1)
        at org.gradle.groovy.scripts.internal.DefaultScriptRunnerFactory$ScriptRunnerImpl.run(DefaultScriptRunnerFactory.java:91)
        ... 182 more

I wanted to see if exiting immediately would lead to a better outcome, as there would be no stack-trace:

if ( System.getenv('MY_ENV_VAR') == null ) {
  print("Environment variable MY_ENV_VAR is not set, you will not be able to download artifact from the repository")
  System.exit(1);
}

The output is shorter, the printed message does not appear anywhere. Plus who knows if there are other unintended side-effects.

Daemon will be stopped at the end of the build
The message received from the daemon indicates that the daemon has disappeared.
Build request sent: Build{id=5ca5bc45-4867-4929-9d95-ea1a542bd965, currentDir=D:\project\release}
Attempting to read last messages from the daemon log...
Daemon pid: 29912
  log file: C:\Users\df0\.gradle\daemon\8.9\daemon-29912.out.log
----- Last  20 lines from daemon log file - daemon-29912.out.log -----
2025-05-14T11:10:37.567+0200 [DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] Command execution: started DaemonCommandExecution[command = Build{id=5ca5bc45-4867-4929-9d95-ea1a542bd965, currentDir=D:\project\release}, connection = DefaultDaemonConnection: socket connection from /127.0.0.1:35782 to /127.0.0.1:35783] after 0.006750000000000001 minutes of idle
2025-05-14T11:10:37.567+0200 [INFO] [org.gradle.launcher.daemon.server.DaemonRegistryUpdater] Marking the daemon as busy, address: [59868c89-5dad-4483-98e8-6438beaf9923 port:35782, addresses:[localhost/127.0.0.1]]
2025-05-14T11:10:37.568+0200 [DEBUG] [org.gradle.launcher.daemon.registry.PersistentDaemonRegistry] Marking busy by address: [59868c89-5dad-4483-98e8-6438beaf9923 port:35782, addresses:[localhost/127.0.0.1]]
2025-05-14T11:10:37.570+0200 [DEBUG] [org.gradle.cache.internal.DefaultFileLockManager] Waiting to acquire exclusive lock on daemon addresses registry.
2025-05-14T11:10:37.571+0200 [DEBUG] [org.gradle.cache.internal.DefaultFileLockManager] Lock acquired on daemon addresses registry.
2025-05-14T11:10:37.574+0200 [DEBUG] [org.gradle.cache.internal.DefaultFileLockManager] Releasing lock on daemon addresses registry.
2025-05-14T11:10:37.575+0200 [DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] resetting idle timer
2025-05-14T11:10:37.575+0200 [DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] daemon is running. Sleeping until state changes.
2025-05-14T11:10:37.577+0200 [INFO] [org.gradle.launcher.daemon.server.exec.StartBuildOrRespondWithBusy] Daemon is about to start building Build{id=5ca5bc45-4867-4929-9d95-ea1a542bd965, currentDir=D:\project\release}. Dispatching build started information...
2025-05-14T11:10:37.578+0200 [DEBUG] [org.gradle.launcher.daemon.server.SynchronizedDispatchConnection] thread 50: dispatching org.gradle.launcher.daemon.protocol.BuildStarted@303cd65d
2025-05-14T11:10:37.582+0200 [DEBUG] [org.gradle.launcher.daemon.server.exec.EstablishBuildEnvironment] Configuring env variables: [PATH, WINEDLLOVERRIDES, Platform, WindowsSdkBinPath, SESSIONNAME, ALLUSERSPROFILE, VCToolsRedistDir, WindowsSdkVerBinPath, VSCMD_VER, =ExitCode, PROGRAMFILES, USERNAME, PWD, ProgramFiles(x86), FPS_BROWSER_USER_PROFILE_STRING, NcpClntDataPath, VSCMD_ARG_HOST_ARCH, DEFAULT_JVM_OPTS, PATHEXT, DriverData, vsconsoleoutput, HOMEPATH, PROCESSOR_IDENTIFIER, PUBLIC, ExtensionSdkDir, =::, SHLVL, FrameworkVersion32, __DOTNET_ADD_64BIT, OneDriveCommercial, __DOTNET_PREFERRED_BITNESS, WindowsSdkDir, FPS_BROWSER_APP_PROFILE_STRING, FrameworkDir32, JAVA_HOME, __VSCMD_PREINIT_VS170COMNTOOLS, UCRTVersion, OneDrive, __VSCMD_PREINIT_PATH, =C:, APPDATA, JAVA_EXE, DLD_PATH, EFC_11376, _, VCINSTALLDIR, CHECKPOINT_DISABLE, LIB, COMPUTERNAME, PROFILEREAD, VSCMD_ARG_TGT_ARCH, __VSCMD_PREINIT_VCToolsVersion, =D:, VisualStudioVersion, HOMEDRIVE, FrameworkVersion, HTTP_PROXY_inactive, NUMBER_OF_PROCESSORS, WindowsSDKLibVersion, USERDOMAIN_ROAMINGPROFILE, VCToolsVersion, WindowsSDK_ExecutablePath_x64, PROCESSOR_LEVEL, SYSTEMDRIVE, TZ, LESSHISTFILE, PROCESSOR_ARCHITECTURE, WGETRC, PSModulePath, EXTERNAL_INCLUDE, LOGNAME, USERDNSDOMAIN, FrameworkVersion64, INFOPATH, VCToolsInstallDir, APP_HOME, SHELL, PASSWORD_STORE_DIR, OLDPWD, TMPDIR, WINDIR, ProgramData,ProgramW6432, SYSSCREENRC, GNUPGHOME, LIBPATH, MINTTY_SHORTCUT, WindowsSDKVersion, VSCMD_ARG_app_plat, LS_COLORS, WindowsSDK_ExecutablePath_x86, LOCALAPPDATA, USERDOMAIN, LOGONSERVER, NETFXSDKDir, WindowsLibPath, DISPLAY, UniversalCRTSdkDir, CommandPromptType, GTK_BASEPATH, SYSTEMROOT, __DOTNET_ADD_32BIT, VCIDEInstallDir, Framework40Version, OS, COMMONPROGRAMFILES, COMSPEC, VS170COMNTOOLS, FrameworkDir64, PROCESSOR_REVISION, USER, CLASSPATH, CommonProgramW6432, FrameworkDir, EDITOR, PRINTER, TEMP, HOSTNAME, USERPROFILE, TMP, CommonProgramFiles(x86)]
2025-05-14T11:10:37.608+0200 [DEBUG] [org.gradle.launcher.daemon.server.exec.LogToClient] About to start relaying all logs to the client via the connection.
2025-05-14T11:10:37.608+0200 [INFO] [org.gradle.launcher.daemon.server.exec.LogToClient] The client will now receive all logging from the daemon (pid: 29912). The daemon log file: C:\Users\df0\.gradle\daemon\8.9\daemon-29912.out.log
2025-05-14T11:10:37.610+0200 [DEBUG] [org.gradle.launcher.daemon.server.exec.RequestStopIfSingleUsedDaemon] Requesting daemon stop after processing Build{id=5ca5bc45-4867-4929-9d95-ea1a542bd965, currentDir=D:\project\release}
2025-05-14T11:10:37.610+0200 [LIFECYCLE] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] Daemon will be stopped at the end of the build
2025-05-14T11:10:37.611+0200 [DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] Stop as soon as idle requested. The daemon is busy
2025-05-14T11:10:37.611+0200 [DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] daemon stop has been requested. Sleeping until state changes.
2025-05-14T11:10:37.612+0200 [DEBUG] [org.gradle.launcher.daemon.server.exec.ExecuteBuild] The daemon has started executing the build.
2025-05-14T11:10:37.612+0200 [DEBUG] [org.gradle.launcher.daemon.server.exec.ExecuteBuild] Executing build with daemon context: DefaultDaemonContext[uid=d0ff3a65-cb1b-4d24-a7c7-66f6f7a185d6,javaHome=D:\jdk-21.0.2,javaVersion=21,daemonRegistryDir=C:\Users\df0\.gradle\daemon,pid=29912,idleTimeout=120000,priority=NORMAL,applyInstrumentationAgent=true,nativeServicesMode=ENABLED,daemonOpts=-XX:+HeapDumpOnOutOfMemoryError,--add-opens=java.base/java.util=ALL-UNNAMED,--add-opens=java.base/java.lang=ALL-UNNAMED,--add-opens=java.base/java.lang.invoke=ALL-UNNAMED,--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED,--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED,--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED,--add-opens=java.base/java.nio.charset=ALL-UNNAMED,--add-opens=java.base/java.net=ALL-UNNAMED,--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED,-Xmx4608M,-Dfile.encoding=UTF-8,-Duser.country=US,-Duser.language=en,-Duser.variant]
Daemon vm is shutting down... The daemon has exited normally or was terminated in response to a user interrupt.
----- End of the daemon log -----


FAILURE: Build failed with an exception.

* What went wrong:
Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
* Exception is:
org.gradle.launcher.daemon.client.DaemonDisappearedException: Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)
        at org.gradle.launcher.daemon.client.DaemonClient.handleDaemonDisappearance(DaemonClient.java:282)
        at org.gradle.launcher.daemon.client.DaemonClient.monitorBuild(DaemonClient.java:255)
        at org.gradle.launcher.daemon.client.DaemonClient.executeBuild(DaemonClient.java:208)
        at org.gradle.launcher.daemon.client.SingleUseDaemonClient.execute(SingleUseDaemonClient.java:63)
        at org.gradle.launcher.daemon.client.SingleUseDaemonClient.execute(SingleUseDaemonClient.java:37)
        at org.gradle.launcher.cli.RunBuildAction.run(RunBuildAction.java:56)
        at org.gradle.internal.Actions$RunnableActionAdapter.execute(Actions.java:167)
        at org.gradle.launcher.cli.DefaultCommandLineActionFactory$ParseAndBuildAction.execute(DefaultCommandLineActionFactory.java:333)
        at org.gradle.launcher.cli.DefaultCommandLineActionFactory$ParseAndBuildAction.execute(DefaultCommandLineActionFactory.java:297)
        at org.gradle.launcher.cli.DebugLoggerWarningAction.execute(DebugLoggerWarningAction.java:74)
        at org.gradle.launcher.cli.DebugLoggerWarningAction.execute(DebugLoggerWarningAction.java:30)
        at org.gradle.launcher.cli.WelcomeMessageAction.execute(WelcomeMessageAction.java:96)
        at org.gradle.launcher.cli.WelcomeMessageAction.execute(WelcomeMessageAction.java:40)
        at org.gradle.launcher.cli.NativeServicesInitializingAction.execute(NativeServicesInitializingAction.java:50)
        at org.gradle.launcher.cli.NativeServicesInitializingAction.execute(NativeServicesInitializingAction.java:27)
        at org.gradle.launcher.cli.ExceptionReportingAction.execute(ExceptionReportingAction.java:41)
        at org.gradle.launcher.cli.ExceptionReportingAction.execute(ExceptionReportingAction.java:26)
        at org.gradle.launcher.cli.DefaultCommandLineActionFactory$WithLogging.execute(DefaultCommandLineActionFactory.java:445)
        at org.gradle.launcher.Main.doAction(Main.java:35)
        at org.gradle.launcher.bootstrap.EntryPoint.run(EntryPoint.java:52)
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
        at java.base/java.lang.reflect.Method.invoke(Method.java:580)
        at org.gradle.launcher.bootstrap.ProcessBootstrap.runNoExit(ProcessBootstrap.java:60)
        at org.gradle.launcher.bootstrap.ProcessBootstrap.run(ProcessBootstrap.java:37)
        at org.gradle.launcher.GradleMain.main(GradleMain.java:31)
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
        at java.base/java.lang.reflect.Method.invoke(Method.java:580)
        at org.gradle.wrapper.BootstrapMainStarter.start(BootstrapMainStarter.java:35)
        at org.gradle.wrapper.WrapperExecutor.execute(WrapperExecutor.java:108)
        at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:63)

ninja: build stopped: subcommand failed.

And at least, in most C and C++ projects, the command to reproduce the error is also often printed out, which helps to diagnose and fix the issue without triggering a build, which might take additional time.

Overview of the build process

To manually build a Java-only Android application, one has to do the following:

  • get dependencies and put them in classpath

  • create R.java from resources

  • compile .java files to .class files

  • optionally optimize .class files

  • compile .class files to .dex files

  • package everything together

  • sign the application

Until 2021 one would have used the tool dx for compiling .class files to .dex; the current program to use should be d8.

Similarly, proguard is not recommended anymore for optimizing .class files, one should use r8. Although, since proguard is still mantained, I guess it is fine to continue to use it.

Last but not least, if you want to publish your application on the Google Play Store, you have to submit an Android Application Bundle (.aab) and not an Android Package.

Since an .apk can be created from an .aab, I suppose that if you also need an .aab, that it is better to only create an aab, and then an apk from it; at least to ensure consistency.

Note 📝
I failed to find a good resource online that covers building a program by hand. Most resources are outdated, do not mention proguard/r8, do not handle aab and aar, or projects with multiple languages.

Scope of this notes

I want to summarize how to create a "hello world" Android Application (as apk and aab) with Java, Kotlin, some native source code, and a couple of resources with a minimal environment.

In particular I wanted to cover following topics:

  • packaging .apk

  • packaging .aab

  • create an .apk from an .aab

  • signing

  • use string and dialog as resources

  • a java function calling a kotlin function

  • a kotlin function calling a java function

  • a java function calling a C++ function

  • a C++ function calling a java function

Topics that will be missing are

  • resolving and downloading external dependencies

  • creating an .aar

Setup

Thankfully Debian already packaged most tools for building a program for Android. This makes it easier to create a reproducible environment.

In the following table, I’ve enlisted some packages and tools that I’ve tested and are provided by the package manager:

Package Tool

google-android-build-tools-36.0.0-installer

/usr/lib/android-sdk/build-tools/36.0.0/{d8,aapt,aapt2,zipaling,apksigner}

google-android-platform-36-installer

/usr/lib/android-sdk/platforms/android-36/android.jar

google-android-ndk-r28-installer

/usr/lib/android-ndk/, /usr/lib/android-sdk/ndk/

apksigner

/usr/lib/android-sdk/build-tools/debian/apksigner

zipalign

/usr/lib/android-sdk/build-tools/debian/zipalign

aapt

/usr/lib/android-sdk/build-tools/debian/aapt

dalvik-exchange

/usr/lib/android-sdk/build-tools/debian/dx

adb

adb

kotlin

kotlinc

openjdk-21-jdk-headless

javac, jar

proguard-cli

proguard

signapk

signapk

There is apparently no package that provides d8 directly, similarly to how dalvik-exchange provides dx directly, but it is part of google-android-build-tools-36.0.0-installer, which conflicts with zipalign, apksigner, and aapt, as it already provides those tools.

signapk is an alternative to apksigner. According to the documentation it has a compatible interface with apksigner, so it is possible to replace one program with another.

Since dx might not work well with never Java feature and development kits, one should generally prefer d8 over dx.

If you want to create an aab, you will need bundletool 🗄️.

Unfortunately there does not seem to exist a corresponding Debian package. At least it is a single .jar file that can be downloaded from GitHub without additional dependencies.

If you are not planning to use Kotlin, then obviously you do not need to install kotlinc, just like installing the ndk is not required if there is no "native code".

There are some additional packages that look interesting; for example android-sdk. According to the Debian Wiki 🗄️, this package is deprecated. There are also android-platform-tools-base, android-sdk-build-tools, and android-sdk-platform-tools; I did not find any information about them, but they are not necessary for the project I made, so I did not investigate further.

Note 📝
some packages might be missing from the current Debian distribution. You can either add packages from other Debian versions, or download the missing packages (and dependencies) from other version and install the manually. In my case, dalvik-exchange was missing from Debian testing and I installed the version from unstable.

Reduce kotlin dependencies

The kotlin package has following dependencies, that are not used by the kotlin compiler kotlinc: ant, libmaven-compiler-plugin-java, libmaven-plugin-tools-java, and libmaven3-core-java.

Those are not required, since I do not plan to use Ant, Maven, or Gradle, and since kotlinc does not depend on them.

With equivs one can install dummy packages to break to dependencies, which means one can avoid to install over 300MB of unused data (depending if you do not have any of the dependencies of those pakages already installed). This makes only sense if you know you will not need packages that rely on ant, libmaven-compiler-plugin-java, libmaven-plugin-tools-java, or libmaven3-core-java. Otherwise, one could grab the Debian kotlin package and modify it’s metadata by removing the unused dependencies.

Hopefully the unnecessary dependencies will be removed 🗄️ with the next update, so that no changes are necessary.

Compile a sample C++ program

First thing I did was trying to compile a C++ program, to see if the toolchain file works. Given the source file main.cpp and the project file CMakeLists.txt

main.cpp
#include <cstdio>

int main(){
  std::puts("Hello World!");
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.30)

project(AndroidApp)

message("C compiler             ${CMAKE_C_COMPILER}")
message("C++ compiler           ${CMAKE_CXX_COMPILER}")
message("ANDROID_NDK            ${ANDROID_NDK}")
message("ANDROID_SDK_ROOT       ${ANDROID_SDK_ROOT}")
message("CMAKE_SYSTEM_VERSION   ${CMAKE_SYSTEM_VERSION}")
message("CMAKE_ANDROID_API      ${CMAKE_ANDROID_API}")
message("ANDROID_PLATFORM       ${ANDROID_PLATFORM}")

add_executable(main main.cpp)

One can compile the source file with following commands

cmake -DANDROID_ABI=x86_64 --toolchain=/usr/lib/android-ndk/build/cmake/android.toolchain.cmake -S. -B/tmp/build && cmake --build /tmp/build && file /tmp/build/main
/tmp/build/main: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /system/bin/linker64, BuildID[sha1]=e07b2e2ac2eae4393938398c3940fbd85c5b930e, with debug_info, not stripped

Create a key for signing the artifacts

Packages needs to be signed; for development, just create a dummy key pair with the following command:

keytool -genkeypair -keystore keystore.jks -alias androidkey -validity 10000 -keyalg RSA -keysize 2048 -storepass android -keypass android

First attempt

After some trial and errors, this is how a barebone CMake file for packaging a minimal Android application could looks like:

CMakeLists.txt
cmake_minimum_required(VERSION 3.31)

project(AndroidApp)

set(CLASSPATH_SEPARATOR ":")
if(CMAKE_HOST_WIN32)
  set(CLASSPATH_SEPARATOR ";") # unfortunately not possible to use ; on all platforms
endif()

if(NOT ANDROID_SDK_ROOT)
  if($ENV{ANDROID_SDK_ROOT})
    set(ANDROID_SDK_ROOT $ENV{ANDROID_SDK_ROOT} CACHE PATH "Path to Android SDK")
  else()
    set(ANDROID_SDK_ROOT "/usr/lib/android-sdk/" CACHE PATH "Path to Android SDK")
  endif()
endif()

if(NOT BUILD_TOOLS)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/build-tools "${ANDROID_SDK_ROOT}/build-tools/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING)
  set(BUILD_TOOLS)
  foreach(child ${children})
    if(IS_DIRECTORY ${ANDROID_SDK_ROOT}/build-tools/${child})
      list(APPEND BUILD_TOOLS ${ANDROID_SDK_ROOT}/build-tools/${child})
    endif()
  endforeach()
endif()
find_program(AAPT      NAMES aapt              REQUIRED PATHS ${BUILD_TOOLS})
find_program(APKSIGNER NAMES apksigner signapk REQUIRED PATHS ${BUILD_TOOLS})
find_program(ZIPALIGN  NAMES zipalign          REQUIRED PATHS ${BUILD_TOOLS})

execute_process(COMMAND "${ZIPALIGN}" OUTPUT_VARIABLE ZIPALIGN_OUTPUT ERROR_VARIABLE ZIPALIGN_OUTPUT OUTPUT_STRIP_TRAILING_WHITESPACE)
string(FIND "${ZIPALIGN_OUTPUT}" " -P " position)
set(ZIPALIGN_ALIGNEMNT_ARG "-p")
if(position GREATER -1)
  set(ZIPALIGN_ALIGNEMNT_ARG "-P" "16") # from android 14(?), different page sizes are supported
endif()
find_program(DX        NAMES dx        REQUIRED PATHS ${BUILD_TOOLS})
find_program(KOTLINC   NAMES kotlinc   REQUIRED)
set(KOTLIN_STDLIB "/usr/share/java/kotlin-stdlib.jar")

find_package(Java 1.8)
find_package(Java COMPONENTS Development)
include(UseJava) # java, jar, and other tools

if(NOT ANDROID_PLATFORM_DIR)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/platforms "${ANDROID_SDK_ROOT}/platforms/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING) # NOTE: also considers ${ANDROID_SDK_ROOT}/build-tools/debian
  list(GET children 0 ANDROID_PLATFORM_DIR)
  if(ANDROID_PLATFORM_DIR STRLESS ANDROID_PLATFORM)
    message(FATAL_ERROR "ERROR: no valid platform found. Found ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR}, but it is less than configured minimum supported version: ${ANDROID_PLATFORM}")
  endif()
  set(ANDROID_PLATFORM_DIR ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR})
endif()
find_file(ANDROID_JAR
  NAMES android.jar
  PATHS ${ANDROID_PLATFORM_DIR}
  REQUIRED NO_CMAKE_FIND_ROOT_PATH
)
set(BASE_CLASSPATH_FOR_JAVAC "${ANDROID_JAR}")
if(KOTLIN_STDLIB)
  # required at least for class kotlin.Metadata
  set(BASE_CLASSPATH_FOR_JAVAC "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${KOTLIN_STDLIB}")
endif()





add_library(hello SHARED
  src/hello.cpp
)

target_link_libraries(hello
  PRIVATE
    log # for logging with __android_log_write
)

set(CLASS_APP_FOLDER "${CMAKE_BINARY_DIR}/class_app_apk")

# PROJECT files
set(INPUTFILES_JAVA
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/MainActivity.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/Hello.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/HelloFromCPP.java
)
set(INPUTFILES_KOTLIN ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/kotlin.kt)

set(STRINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/values/strings.xml")
set(IDS         "${CMAKE_CURRENT_SOURCE_DIR}/res/values/ids.xml")
set(LAYOUTS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/layout/activity_main.xml")
set(MANIFEST    "${CMAKE_CURRENT_SOURCE_DIR}/src/AndroidManifest.xml")


set(FINAL_APK "${CMAKE_CURRENT_BINARY_DIR}/app.apk")

# FIXME: should enlist all resources
set(RESOURCES "${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml")
add_custom_command(
  OUTPUT  ${RESOURCES}
  DEPENDS ${LAYOUTS_XML} ${STRINGS_XML}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/drawable/
  COMMAND ${CMAKE_COMMAND} -E copy ${LAYOUTS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${STRINGS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/strings.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${IDS}         ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/ids.xml
  COMMENT "copy relevant resources to build folder"
  VERBATIM
)

set(CLASSPATH_GEN_APP ${CMAKE_CURRENT_BINARY_DIR}/gen_app_apk/)
set(R_JAVA ${CLASSPATH_GEN_APP}/com/example/R.java)
add_custom_command(
  OUTPUT  ${R_JAVA}
  DEPENDS ${MANIFEST} ${STRINGS_XML} ${LAYOUTS_XML} ${RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASSPATH_GEN_APP}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${PROGUARDRULES_D_APP}
  COMMAND ${AAPT} package -f -m -J ${CLASSPATH_GEN_APP} -S ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk -M ${MANIFEST} -I ${ANDROID_JAR}
  COMMENT "create R.java file with aapt"
  VERBATIM
  CODEGEN
)

set(CLASS_FILES_KOTLIN)
foreach(file IN LISTS INPUTFILES_KOTLIN)
  string(REGEX REPLACE "[.]kt$" ".class" classfile ${file})
  set(classfile "${CLASS_APP_FOLDER}/kotlin/${classfile}")
  list(APPEND CLASS_FILES_KOTLIN ${classfile})
endforeach()
add_custom_command(
  OUTPUT  ${CLASS_FILES_KOTLIN}
  DEPENDS ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA}
  COMMAND ${KOTLINC} -jvm-target 1.8 -classpath "${ANDROID_JAR}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_APP}" -d ${CLASS_APP_FOLDER}/kotlin ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA}
  COMMENT "kotlinc"
  VERBATIM
)

set(CLASS_FILES_JAVA)
foreach(file IN LISTS INPUTFILES_JAVA)
  string(REGEX REPLACE "[.]java$" ".class" classfile ${file})
  set(classfile "${CLASS_APP_FOLDER}/java/${classfile}")
  list(APPEND CLASS_FILES_JAVA ${classfile})
endforeach()
add_custom_command(
  OUTPUT  ${CLASS_FILES_JAVA}
  DEPENDS ${INPUTFILES_JAVA} ${R_JAVA} ${CLASS_FILES_KOTLIN}
  COMMAND  ${Java_JAVAC_EXECUTABLE} -Xlint:all --class-path "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${CLASS_APP_FOLDER}/kotlin${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_APP}" --source 8 --target 8 -d ${CLASS_APP_FOLDER}/java ${INPUTFILES_JAVA};
  COMMENT "javac"
  VERBATIM
)

set(CLASS_FILES ${CLASS_FILES_KOTLIN} ${CLASS_FILES_JAVA})
set(DIR_CLASS_FILES ${CLASS_APP_FOLDER}/java ${CLASS_APP_FOLDER}/kotlin)

set(DEX_FILE ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/classes.dex)
add_custom_command(
  OUTPUT  ${DEX_FILE}
  DEPENDS ${MANIFEST} ${CLASS_FILES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk
  COMMAND ${DX} --dex --min-sdk-version=23 --output=${DEX_FILE} ${DIR_CLASS_FILES}
  COMMENT "convert .class to .dex file (dx)"
  VERBATIM
)

set(UNSIGNED_APK "${CMAKE_CURRENT_BINARY_DIR}/app.unsigned.apk")
add_custom_command(
  OUTPUT  ${UNSIGNED_APK}
  DEPENDS ${MANIFEST} ${DEX_FILE} hello ${RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/lib/${ANDROID_ABI}
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_BINARY_DIR}/libhello.so ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/lib/${ANDROID_ABI}
  COMMAND ${AAPT} package -f -F ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk -I ${ANDROID_JAR} -M ${MANIFEST} -S ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk base_app_apk
  COMMAND ${ZIPALIGN} ${ZIPALIGN_ALIGNEMNT_ARG} -f 4 ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk ${UNSIGNED_APK}
  COMMAND ${CMAKE_COMMAND} -E remove ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk
  COMMENT "create unsigned apk"
  VERBATIM
)

set(KEYSTORE "${CMAKE_CURRENT_SOURCE_DIR}/keystore.jks")
add_custom_command(
  OUTPUT  ${FINAL_APK}
  DEPENDS ${UNSIGNED_APK}
  COMMAND ${ZIPALIGN} ${ZIPALIGN_ALIGNEMNT_ARG} -f 4 ${UNSIGNED_APK} ${FINAL_APK}
  COMMAND ${APKSIGNER} sign --in ${FINAL_APK} -ks ${KEYSTORE} --ks-key-alias androidkey --ks-pass pass:android --key-pass pass:android --min-sdk-version 21 --v2-signing-enabled true --v1-signing-enabled false
  COMMENT "create final signed apk"
  VERBATIM
)

add_custom_target(apk ALL DEPENDS ${FINAL_APK})

Some things to note.

The first thing is that in a project with both Java and Kotlin source files, one needs to handle the dependencies between both languages. Java files depend on Java and class files, thus the first thing to do is to compile all, if any, Kotlin source files, to .class files. The compiled .class files can be added to the CLASSPATH for the Java compiler.

Easy-peasy.

Except that Kotlin code can depend on Java code, creating a circular dependency between the languages, and even between source files.

The kotlinc compiler accepts Java files as dependencies 🗄️ for Kotlin files. This makes it possible to make a more strict division between the two languages, without having to throw everything together.

Native code and code that compiles to .class files does not interact, except when you package everything together.

The second thing is that the output of javac is hard to predict, as it depends on the content of the java files. Thus, tracking which files have been compiled and are out-of-date is generally broken.

Third: apparently a lot of tools like to work with directories instead of files; for example aapt. Since I do not want "temporary files in the source directory" (for example a file I just copied over) to be automatically included in the project, the only solution I’ve found is to copy the relevant files in the build folder. Thus the custom command for copying resources in the build folder.

Fourth, if javac is new enough, the tool dx will complain if you do not use --target 8. As I found out later, dx has been deprecated since 2021 🗄️, and replaced by d8.

Fifth, at least one path is completely hard-coded (/usr/share/java/kotlin-stdlib.jar) and for other paths the hints given to find_program are hard-coded too. Assuming that most people install the tools through Android Studio and not the package manager, the CMake file should provide a way to define additional paths where to search for libraries and tools.

Last but not least, the CMake file is by design very low-level and error-prone. There are no targets, and no functions to encapsulate common logic. I wanted to provide a simple example on how to create and .apk. You should not consider this project, even for most toy projects. This also holds for the rest of the presented CMake files in this notes.

Interaction between class files and native code

It is not strictly true that native code and code that compiles to .class files do not interact.

javac can create header files with the optional parameter -h, and before javac had that functionality, there was a separate tool named javah that worked on .class and .java files, but has been officially removed since JDK 10 🗄️.

Since similarly to other tools, the content of the input files determines the name of the output file, it is not that easy to explicitely track what files are out of date.

Since javah is not supported anymore, I assumed that a similar functionality exists also for Kotlin, but it turns out there is an open issue since 2019 🗄️.

Instead of generating header files, and thus making the native code depend on the Java code, one could verify after building both the library and class files if all functions signature in the class files can be found in the libraries. The advantage of this method is that it removes a build-time dependency between different languages, making it easier to parallelize the build, and does not require to parse multiple languages for the JVM (Java, Kotlin, Scala, …​) and multiple languages that are acompile to native code (C, C++, zig, …​) but only requires to parse .class and .so files.

The main advantage of this approach, is that it might help to find exports that are missing, or exports that are not necessary anymore.

The main disadvantage is that it is not able to verify if the function signature match. To verify if the function signatures match, one needs the functions declarations from the native source code, which means parsing the source code.

With the header files, the compiler itself will determine, if the header file has been included at the right location and the code is written in a certain way, if the signature is correct.

Improvements

After some thought, I decided that working with jar files instead of .class file makes more sense. In a C++ project, it is also easier to reason about libraries instead of single object files.

Most important, it solves the issue that it is hard to predict the output of javac.

A second improvement is the integration of proguard, an optimizer for .class files, for which it is also difficult to predict what files it will create. I would have tried to integrate r8 too, but it does not seem to be packaged anywhere, and I did not find out (yet) how to download it cleanly.

A third improvement is the usage of d8 instead of dx.

CMakeLists.txt
cmake_minimum_required(VERSION 3.31)

project(AndroidApp)

set(CLASSPATH_SEPARATOR ":")
if(CMAKE_HOST_WIN32)
  set(CLASSPATH_SEPARATOR ";") # unfortunately not possible to use ; on all platforms
endif()

if(NOT ANDROID_SDK_ROOT)
  if($ENV{ANDROID_SDK_ROOT})
    set(ANDROID_SDK_ROOT $ENV{ANDROID_SDK_ROOT} CACHE PATH "Path to Android SDK")
  else()
    set(ANDROID_SDK_ROOT "/usr/lib/android-sdk/" CACHE PATH "Path to Android SDK")
  endif()
endif()

if(NOT BUILD_TOOLS)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/build-tools "${ANDROID_SDK_ROOT}/build-tools/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING)
  set(BUILD_TOOLS)
  foreach(child ${children})
    if(IS_DIRECTORY ${ANDROID_SDK_ROOT}/build-tools/${child})
      list(APPEND BUILD_TOOLS ${ANDROID_SDK_ROOT}/build-tools/${child})
    endif()
  endforeach()
endif()
find_program(AAPT      NAMES aapt              REQUIRED PATHS ${BUILD_TOOLS})
find_program(AAPT2     NAMES aapt2             REQUIRED PATHS ${BUILD_TOOLS})
find_program(APKSIGNER NAMES apksigner signapk REQUIRED PATHS ${BUILD_TOOLS})
find_program(ZIPALIGN  NAMES zipalign          REQUIRED PATHS ${BUILD_TOOLS})

execute_process(COMMAND "${ZIPALIGN}" OUTPUT_VARIABLE ZIPALIGN_OUTPUT ERROR_VARIABLE ZIPALIGN_OUTPUT OUTPUT_STRIP_TRAILING_WHITESPACE)
string(FIND "${ZIPALIGN_OUTPUT}" " -P " position)
set(ZIPALIGN_ALIGNEMNT_ARG "-p")
if(position GREATER -1)
  set(ZIPALIGN_ALIGNEMNT_ARG "-P" "16") # from android 14(?), different page sizes are supported
endif()
find_program(DX        NAMES dx        PATHS ${BUILD_TOOLS})
find_program(D8        NAMES d8        PATHS ${BUILD_TOOLS})
if(NOT DX AND NOT D8)
  message(FATAL_ERROR "At least dx or d8 is required, none found in ${BUILD_TOOLS}")
endif()
find_program(KOTLINC   NAMES kotlinc   REQUIRED)
set(KOTLIN_STDLIB "/usr/share/java/kotlin-stdlib.jar")
find_program(PROGUARD  NAMES proguard)

find_package(Java 1.8)
find_package(Java COMPONENTS Development)
include(UseJava) # java, jar, and other tools
set(BUNDLETOOL ${Java_JAVA_EXECUTABLE} -jar ${CMAKE_SOURCE_DIR}/bundletool-all-1.18.1.jar)

if(NOT ANDROID_PLATFORM_DIR)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/platforms "${ANDROID_SDK_ROOT}/platforms/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING)
  list(GET children 0 ANDROID_PLATFORM_DIR)
  if(ANDROID_PLATFORM_DIR STRLESS ANDROID_PLATFORM)
    message(FATAL_ERROR "ERROR: no valid platform found. Found ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR}, but it is less than configured minimum supported version: ${ANDROID_PLATFORM}")
  endif()
  set(ANDROID_PLATFORM_DIR ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR})
endif()
find_file(ANDROID_JAR
  NAMES android.jar
  PATHS ${ANDROID_PLATFORM_DIR}
  REQUIRED NO_CMAKE_FIND_ROOT_PATH
)

add_executable(main src/main.cpp)


add_library(hello SHARED
  src/hello.cpp
)

target_link_libraries(hello
  PRIVATE
    log # required for logging with __android_log_write
)

set(CLASS_APP_FOLDER "${CMAKE_BINARY_DIR}/class_app_apk")



# PROJECT files
set(INPUTFILES_JAVA
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/MainActivity.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/Hello.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/HelloFromCPP.java
)
set(INPUTFILES_KOTLIN ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/kotlin.kt)

set(BASE_CLASSPATH_FOR_JAVAC "${ANDROID_JAR}")
if(KOTLIN_STDLIB)
  # required at least for class kotlin.Metadata
  set(BASE_CLASSPATH_FOR_JAVAC "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${KOTLIN_STDLIB}")
endif()


set(STRINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/values/strings.xml")
set(IDS         "${CMAKE_CURRENT_SOURCE_DIR}/res/values/ids.xml")
set(LAYOUTS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/layout/activity_main.xml")
set(MANIFEST    "${CMAKE_CURRENT_SOURCE_DIR}/src/AndroidManifest.xml")


# create APK directly
set(FINAL_APK "${CMAKE_CURRENT_BINARY_DIR}/app.apk")

# FIXME: iterate like java files
set(RESOURCES "${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml")
add_custom_command(
  OUTPUT  ${RESOURCES}
  DEPENDS ${LAYOUTS_XML} ${STRINGS_XML}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/drawable/
  COMMAND ${CMAKE_COMMAND} -E copy ${LAYOUTS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${STRINGS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/strings.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${IDS}         ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/ids.xml
  COMMENT "copy relevant resources to build folder"
  VERBATIM
)


set(CLASSPATH_GEN_APP ${CMAKE_CURRENT_BINARY_DIR}/gen_app_apk/)
set(PROGUARDRULES_D_APP ${CMAKE_CURRENT_BINARY_DIR}/proguard_rules/)
set(PROGUARDRULES_F_APP ${PROGUARDRULES_D_APP}/aapt.pro)
set(R_JAVA ${CLASSPATH_GEN_APP}/com/example/R.java)
add_custom_command(
  OUTPUT  ${R_JAVA} ${PROGUARDRULES_F_APP}
  DEPENDS ${MANIFEST} ${STRINGS_XML} ${LAYOUTS_XML} ${RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASSPATH_GEN_APP}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${PROGUARDRULES_D_APP}
  COMMAND ${AAPT} package -f -m -J ${CLASSPATH_GEN_APP} -S ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk -G ${PROGUARDRULES_F_APP} -M ${MANIFEST} -I ${ANDROID_JAR}
  COMMENT "create R.java file with aapt"
  VERBATIM
  CODEGEN
)

set(CLASS_FILES_KOTLIN ${CLASS_APP_FOLDER}/kotlin.jar)
add_custom_command(
  OUTPUT  ${CLASS_FILES_KOTLIN}
  DEPENDS ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASS_APP_FOLDER} # not required when not creating jar file
  COMMAND ${KOTLINC} -jvm-target 1.8 -classpath "${ANDROID_JAR}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_APP}" -d ${CLASS_FILES_KOTLIN} ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA};
  COMMENT "kotlinc"
  VERBATIM
)

set(CLASS_FILES_JAVA ${CLASS_APP_FOLDER}/java.jar)
add_custom_command(
  OUTPUT  ${CLASS_FILES_JAVA}
  DEPENDS ${INPUTFILES_JAVA} ${R_JAVA} ${CLASS_FILES_KOTLIN}
  COMMAND  ${Java_JAVAC_EXECUTABLE} -Xlint:all --class-path "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_APP}" --source 8 --target 8 -d ${CLASS_APP_FOLDER}/java ${INPUTFILES_JAVA};
  COMMAND  ${Java_JAR_EXECUTABLE} --date "1980-01-01T12:01:00Z" --create --no-manifest --file ${CLASS_FILES_JAVA} -C ${CLASS_APP_FOLDER}/java .;
  COMMENT "javac + jar"
  VERBATIM
)

if(PROGUARD)
  set(CLASSPATH_FOR_PROGUARD ${ANDROID_JAR})
  if(CLASS_FILES_KOTLIN)
    # required at least for class kotlin.Metadata
    set(CLASSPATH_FOR_PROGUARD "${CLASSPATH_FOR_PROGUARD}${CLASSPATH_SEPARATOR}/usr/share/java/kotlin-stdlib.jar")
  endif()
  set(OPTIMIZED_CLASS ${CLASS_APP_FOLDER}/optimized.jar)
  add_custom_command(
    OUTPUT  ${OPTIMIZED_CLASS}
    DEPENDS ${CLASS_FILES_KOTLIN} ${CLASS_FILES_JAVA}
    COMMAND ${PROGUARD} -dontobfuscate -libraryjars ${CLASSPATH_FOR_PROGUARD}
                        -injars ${CLASS_FILES_JAVA}${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN}
                        -outjars ${OPTIMIZED_CLASS}
                        -dontwarn org.jetbrains.annotations.NotNull # used only for static analysis, safe to ignore, no need to add other jars to classpath
                        # should be user-supplied; callback for jni
                        -keep "public class com.example.hello.HelloFromCPP{public static void log();}"
                        -include ${PROGUARDRULES_F_APP} # references com.example.MainActivity
    COMMENT "optimize .class files"
    VERBATIM
  )
  set(CLASS_FILES ${OPTIMIZED_CLASS})
else()
  set(CLASS_FILES ${CLASS_FILES_KOTLIN} ${CLASS_FILES_JAVA})
endif()

set(DEX_FILE ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/classes.dex)
if(D8) # prefer d8 to dx if available
  add_custom_command(
    OUTPUT  ${DEX_FILE}
    DEPENDS ${MANIFEST} ${CLASS_FILES}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk
    COMMAND ${D8} --output ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/ --classpath ${ANDROID_JAR} ${CLASS_FILES}
    COMMENT "convert .class to .dex file (d8)"
    VERBATIM
  )
else()
  add_custom_command(
    OUTPUT  ${DEX_FILE}
    DEPENDS ${MANIFEST} ${CLASS_FILES}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk
    COMMAND ${DX} --dex --min-sdk-version=23 --output=${DEX_FILE} ${CLASS_FILES}
    COMMENT "convert .class to .dex file (dx)"
    VERBATIM
  )
endif()

set(UNSIGNED_APK "${CMAKE_CURRENT_BINARY_DIR}/app.unsigned.apk")
add_custom_command(
  OUTPUT  ${UNSIGNED_APK}
  DEPENDS ${MANIFEST} ${DEX_FILE} hello ${RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/lib/${ANDROID_ABI}
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_BINARY_DIR}/libhello.so ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/lib/${ANDROID_ABI}
  COMMAND ${AAPT} package -f -F ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk -I ${ANDROID_JAR} -M ${MANIFEST} -S ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk base_app_apk
  COMMAND ${ZIPALIGN} ${ZIPALIGN_ALIGNEMNT_ARG} -f 4 ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk ${UNSIGNED_APK}
  COMMAND ${CMAKE_COMMAND} -E remove ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk
  COMMENT "create unsigned apk"
  VERBATIM
)

set(KEYSTORE "${CMAKE_CURRENT_SOURCE_DIR}/keystore.jks")
add_custom_command(
  OUTPUT  ${FINAL_APK}
  DEPENDS ${UNSIGNED_APK}
  COMMAND ${ZIPALIGN} ${ZIPALIGN_ALIGNEMNT_ARG} -f 4 ${UNSIGNED_APK} ${FINAL_APK}
  COMMAND ${APKSIGNER} sign --in ${FINAL_APK} -ks ${KEYSTORE} --ks-key-alias androidkey --ks-pass pass:android --key-pass pass:android --min-sdk-version 21 --v2-signing-enabled true --v1-signing-enabled false
  COMMENT "create final signed apk"
  VERBATIM
)

add_custom_target(apk ALL DEPENDS ${FINAL_APK})

After the first implementation, I immediately verified if kotlinc and proguard generated the same jar archive at every run. Unfortunately not; at least the timestamp differs.

There is even an article about the importance of Reproducible Kotlin Compiler Artifacts 🗄️, and only at this point I realize that the version of kotlin packaged for Debian is from 2018 (Kotlin 1.3). The current version of the kotlin compiler (currently version is 2.1.21) seems to create by default a reproducible archive.

At least aapt creates by default a reproducible apk file.

Add support for bundle files

For creating an android bundle, one should use aapt2 instead of aapt, and bundletool.

bundletool is currently not packaged for Debian, at least it is easy to add it to your system; just download the .jar from GitHub, and execute it with java -jar. No additional files are required.

Note 📝
according to the documentation 🗄️, one should download aapt2 separately for creating a working aab. As far as I could see and test, this is not the case. Maybe the documentation is out-of-date.

An important thing to realize is that the R.java file created by aapt2 is different from R.java created by aapt.

A R.java file mostly contains integral constants, for example:

public final class R {
  public static final class id {
    public static final int my_text=0x7f010000;
  }
  public static final class layout {
    public static final int activity_main=0x7f020000;
  }
  public static final class string {
    public static final int app_name=0x7f030000;
    public static final int made_with_cmake=0x7f030001;
  }
}

Since one of the few optimisations is constant propagation for native types, one should not reuse class files that depend on R.java.

What happens in practice is that when the application is executed, the linked resource will not be found, or the wrong will be selected. I’ve spent too many hours debugging this issue, since at the beginning I did not realize that aapt2 also had an option for creating R.java.

CMakeLists.txt
cmake_minimum_required(VERSION 3.31)

project(AndroidApp)

set(CLASSPATH_SEPARATOR ":")
if(CMAKE_HOST_WIN32)
  set(CLASSPATH_SEPARATOR ";") # unfortunately not possible to use ; on all platforms
endif()

if(NOT ANDROID_SDK_ROOT)
  if($ENV{ANDROID_SDK_ROOT})
    set(ANDROID_SDK_ROOT $ENV{ANDROID_SDK_ROOT} CACHE PATH "Path to Android SDK")
  else()
    set(ANDROID_SDK_ROOT "/usr/lib/android-sdk/" CACHE PATH "Path to Android SDK")
  endif()
endif()

if(NOT BUILD_TOOLS)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/build-tools "${ANDROID_SDK_ROOT}/build-tools/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING)
  set(BUILD_TOOLS)
  foreach(child ${children})
    if(IS_DIRECTORY ${ANDROID_SDK_ROOT}/build-tools/${child})
      list(APPEND BUILD_TOOLS ${ANDROID_SDK_ROOT}/build-tools/${child})
    endif()
  endforeach()
endif()
find_program(AAPT2     NAMES aapt2             REQUIRED PATHS ${BUILD_TOOLS})
find_program(APKSIGNER NAMES apksigner signapk REQUIRED PATHS ${BUILD_TOOLS})
find_program(ZIPALIGN  NAMES zipalign          REQUIRED PATHS ${BUILD_TOOLS})

execute_process(COMMAND "${ZIPALIGN}" OUTPUT_VARIABLE ZIPALIGN_OUTPUT ERROR_VARIABLE ZIPALIGN_OUTPUT OUTPUT_STRIP_TRAILING_WHITESPACE)
string(FIND "${ZIPALIGN_OUTPUT}" " -P " position)
set(ZIPALIGN_ALIGNEMNT_ARG "-p")
if(position GREATER -1)
  set(ZIPALIGN_ALIGNEMNT_ARG "-P" "16") # from android 14(?), different page sizes are supported
endif()
find_program(DX        NAMES dx        PATHS ${BUILD_TOOLS})
find_program(D8        NAMES d8        PATHS ${BUILD_TOOLS})
if(NOT DX AND NOT D8)
  message(FATAL_ERROR "At least dx or d8 is required, none found in ${BUILD_TOOLS}")
endif()
find_program(KOTLINC   NAMES kotlinc   REQUIRED)
set(KOTLIN_STDLIB "/usr/share/java/kotlin-stdlib.jar")
find_program(PROGUARD  NAMES proguard)

find_package(Java 1.8)
find_package(Java COMPONENTS Development)
include(UseJava) # java, jar, and other tools
set(BUNDLETOOL ${Java_JAVA_EXECUTABLE} -jar ${CMAKE_SOURCE_DIR}/bundletool-all-1.18.1.jar)

if(NOT ANDROID_PLATFORM_DIR)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/platforms "${ANDROID_SDK_ROOT}/platforms/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING)
  list(GET children 0 ANDROID_PLATFORM_DIR)
  if(ANDROID_PLATFORM_DIR STRLESS ANDROID_PLATFORM)
    message(FATAL_ERROR "ERROR: no valid platform found. Found ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR}, but it is less than configured minimum supported version: ${ANDROID_PLATFORM}")
  endif()
  set(ANDROID_PLATFORM_DIR ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR})
endif()
find_file(ANDROID_JAR
  NAMES android.jar
  PATHS ${ANDROID_PLATFORM_DIR}
  REQUIRED NO_CMAKE_FIND_ROOT_PATH
)

add_executable(main src/main.cpp)


add_library(hello SHARED
  src/hello.cpp
)

target_link_libraries(hello
  PRIVATE
    log # required for logging with __android_log_write
)

# PROJECT files
set(INPUTFILES_JAVA
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/MainActivity.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/Hello.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/HelloFromCPP.java
)
set(INPUTFILES_KOTLIN ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/kotlin.kt)

set(BASE_CLASSPATH_FOR_JAVAC "${ANDROID_JAR}")
if(KOTLIN_STDLIB)
  # required at least for class kotlin.Metadata
  set(BASE_CLASSPATH_FOR_JAVAC "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${KOTLIN_STDLIB}")
endif()


set(STRINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/values/strings.xml")
set(IDS         "${CMAKE_CURRENT_SOURCE_DIR}/res/values/ids.xml")
set(LAYOUTS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/layout/activity_main.xml")
set(MANIFEST    "${CMAKE_CURRENT_SOURCE_DIR}/src/AndroidManifest.xml")

# FIXME: iterate like java files
set(RESOURCES "${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml")
add_custom_command(
  OUTPUT  ${RESOURCES}
  DEPENDS ${LAYOUTS_XML} ${STRINGS_XML}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/drawable/
  COMMAND ${CMAKE_COMMAND} -E copy ${LAYOUTS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${STRINGS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/strings.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${IDS}         ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/ids.xml
  COMMENT "copy relevant resources to build folder"
  VERBATIM
)

set(FLAT_RESOURCES ${CMAKE_BINARY_DIR}/flat_res/layout_activity_main.xml.flat ${CMAKE_BINARY_DIR}/flat_res/values_strings.arsc.flat ${CMAKE_BINARY_DIR}/flat_res/values_ids.arsc.flat)
add_custom_command(
  OUTPUT  ${FLAT_RESOURCES}
  DEPENDS ${STRINGS_XML} ${IDS} ${LAYOUTS_XML}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/flat_res # otherwise aapt2 creates zip archive
  COMMAND ${AAPT2} compile -o ${CMAKE_BINARY_DIR}/flat_res/ ${STRINGS_XML} ${IDS} ${LAYOUTS_XML}
  COMMENT "compile resources to flat"
  VERBATIM
)

set(CLASSPATH_GEN_BUNDLE ${CMAKE_CURRENT_BINARY_DIR}/gen_app_bundle/)
set(R_JAVA_BUNDLE ${CLASSPATH_GEN_BUNDLE}/com/example/R.java)


set(PROGUARDRULES_D_BUNDLE ${CMAKE_CURRENT_BINARY_DIR}/proguard_rules_bundle/)
set(PROGUARDRULES_F_BUNDLE ${PROGUARDRULES_D_BUNDLE}/aapt.pro)

add_custom_command(
  OUTPUT  ${R_JAVA_BUNDLE} ${PROGUARDRULES_F_BUNDLE}
  DEPENDS ${FLAT_RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/output_ # otherwise aapt2 link errors
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASSPATH_GEN_BUNDLE}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${PROGUARDRULES_D_BUNDLE}
  COMMAND ${AAPT2} link --proto-format -o ${CMAKE_BINARY_DIR}/output_
                        -I ${ANDROID_JAR}
                        --manifest ${MANIFEST}
                        -R ${FLAT_RESOURCES}
                        --output-to-dir
                        --java ${CLASSPATH_GEN_BUNDLE}/
                        --proguard ${PROGUARDRULES_F_BUNDLE}
  COMMENT "aapt2 link and create R.java"
  VERBATIM
  CODEGEN
)


set(CLASS_BUNDLE_FOLDER "${CMAKE_BINARY_DIR}/class_app_bundle")

set(CLASS_FILES_KOTLIN_BUNDLE "${CLASS_BUNDLE_FOLDER}/kotlin.jar")
add_custom_command(
  OUTPUT  ${CLASS_FILES_KOTLIN_BUNDLE}
  DEPENDS ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA_BUNDLE}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASS_BUNDLE_FOLDER}
  COMMAND ${KOTLINC} -jvm-target 1.8 -classpath "${ANDROID_JAR}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_BUNDLE}" -d ${CLASS_FILES_KOTLIN_BUNDLE} ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA_BUNDLE};
  COMMENT "kotlinc (bundle)"
  VERBATIM
)

set(CLASS_FILES_JAVA_BUNDLE "${CLASS_BUNDLE_FOLDER}/java.jar")
add_custom_command(
  OUTPUT  ${CLASS_FILES_JAVA_BUNDLE}
  DEPENDS ${INPUTFILES_JAVA} ${R_JAVA_BUNDLE} ${CLASS_FILES_KOTLIN_BUNDLE}
  COMMAND  ${Java_JAVAC_EXECUTABLE} -Xlint:all --class-path "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN_BUNDLE}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_BUNDLE}" --source 8 --target 8 -d ${CLASS_BUNDLE_FOLDER}/java ${INPUTFILES_JAVA};
  COMMAND  ${Java_JAR_EXECUTABLE} --create --no-manifest --file ${CLASS_FILES_JAVA_BUNDLE} -C ${CLASS_BUNDLE_FOLDER}/java .;
  COMMENT "javac + jar (bundle)"
  VERBATIM
)


if(PROGUARD)
  set(CLASSPATH_FOR_PROGUARD ${ANDROID_JAR})
  if(KOTLIN_STDLIB)
    # required at least for class kotlin.Metadata
    set(CLASSPATH_FOR_PROGUARD "${CLASSPATH_FOR_PROGUARD}${CLASSPATH_SEPARATOR}${KOTLIN_STDLIB}")
  endif()
  set(OPTIMIZED_CLASS_BUNDLE ${CLASS_BUNDLE_FOLDER}/optimized.jar)
  add_custom_command(
    OUTPUT  ${OPTIMIZED_CLASS_BUNDLE}
    DEPENDS ${CLASS_FILES_KOTLIN_BUNDLE} ${CLASS_FILES_JAVA_BUNDLE}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASS_BUNDLE_FOLDER}/optimized/
    COMMAND ${PROGUARD} -dontobfuscate -libraryjars ${CLASSPATH_FOR_PROGUARD}
                        -injars ${CLASS_FILES_JAVA_BUNDLE}/${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN_BUNDLE}
                        -outjars ${OPTIMIZED_CLASS_BUNDLE}
                        -dontwarn org.jetbrains.annotations.NotNull # used only for static analysis, safe to ignore, no need to add other jars to classpath
                        # should be user-supplied; callback for jni
                        -keep "public class com.example.hello.HelloFromCPP{public static void log();}"
                        -include ${PROGUARDRULES_F_BUNDLE} # references com.example.MainActivity
    COMMENT "optimize .class files (bundle)"
    VERBATIM
  )
  set(CLASS_FILES_BUNDLE ${OPTIMIZED_CLASS_BUNDLE})
else()
  set(CLASS_FILES_BUNDLE ${CLASS_FILES_KOTLIN_BUNDLE} ${CLASS_FILES_JAVA_BUNDLE})
endif()

# convert .class to dex
set(DEX_FILE_BUNDLE ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle/classes.dex)
if(D8)
  add_custom_command(
    OUTPUT  ${DEX_FILE_BUNDLE}
    DEPENDS ${MANIFEST} ${CLASS_FILES_BUNDLE}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle
    COMMAND ${D8} --output ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle/ --classpath ${ANDROID_JAR} ${CLASS_FILES_BUNDLE}
    COMMENT "convert .class to .dex file (d8)"
    VERBATIM
  )
else()
  add_custom_command(
    OUTPUT  ${DEX_FILE_BUNDLE}
    DEPENDS ${MANIFEST} ${CLASS_FILES_BUNDLE}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle
    COMMAND ${DX} --dex --min-sdk-version=23 --output=${DEX_FILE_BUNDLE} ${CLASS_FILES_BUNDLE}
    COMMENT "convert .class to .dex file (bundle)"
    VERBATIM
  )
endif()



add_custom_command(
  OUTPUT  ${CMAKE_BINARY_DIR}/base_for_bundle/resources.pb
  DEPENDS ${DEX_FILE_BUNDLE} hello

  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/base_for_bundle

  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_BINARY_DIR}/output_/resources.pb ${CMAKE_BINARY_DIR}/base_for_bundle/resources.pb


  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/base_for_bundle/manifest
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_BINARY_DIR}/output_/AndroidManifest.xml ${CMAKE_BINARY_DIR}/base_for_bundle/manifest/AndroidManifest.xml

  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/base_for_bundle/dex
  COMMAND ${CMAKE_COMMAND} -E copy ${DEX_FILE_BUNDLE} ${CMAKE_BINARY_DIR}/base_for_bundle/dex/classes.dex


  COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_BINARY_DIR}/output_/res ${CMAKE_BINARY_DIR}/base_for_bundle/res

  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_for_bundle/lib/${ANDROID_ABI}
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_BINARY_DIR}/libhello.so ${CMAKE_CURRENT_BINARY_DIR}/base_for_bundle/lib/${ANDROID_ABI}

  COMMENT "collect resources for bundle"
  VERBATIM
)


set(BASE_ZIP "${CMAKE_CURRENT_BINARY_DIR}/base.zip" )
add_custom_command( # separate command because of WORKING_DIRECTORY
  OUTPUT  ${BASE_ZIP}
  DEPENDS ${CMAKE_BINARY_DIR}/base_for_bundle/resources.pb
  COMMAND ${CMAKE_COMMAND} -E tar "cf" ${CMAKE_BINARY_DIR}/base.zip --format=zip -- .
  WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/base_for_bundle/
  COMMENT "create base.zip"
  VERBATIM
)

set(FINAL_BUNDLE "${CMAKE_CURRENT_BINARY_DIR}/app.aab")
add_custom_command(
  OUTPUT  ${FINAL_BUNDLE}
  DEPENDS ${BASE_ZIP}

  COMMAND ${BUNDLETOOL} build-bundle --modules=${BASE_ZIP} --output=${FINAL_BUNDLE}
  # FIXME: is it possible to create only v2 signature?
  COMMAND ${BUNDLETOOL} build-apks --bundle=${FINAL_BUNDLE}
                                   --output=${CMAKE_CURRENT_BINARY_DIR}/app.apks
                                   --mode=universal
                                   --ks=${KEYSTORE}
                                   --ks-pass pass:android
                                   --ks-key-alias=androidkey
                                   --key-pass pass:android
  COMMAND ${CMAKE_COMMAND} -E tar x ${CMAKE_CURRENT_BINARY_DIR}/app.apks universal.apk
  COMMENT "create final signed aab+apk"
  VERBATIM
)

add_custom_target(aab ALL DEPENDS ${FINAL_BUNDLE})

Features of the CMake project

I put the focus on the project itself and not on the source files, but I tried to ensure that following works correctly:

  • packaging .apk

  • packaging .aab

  • create an .apk from an .aab

  • signing

  • use string and dialog as resources

  • a java function calling a kotlin function

  • a kotlin function calling a java function

  • compiling a native (in this case C++) library

  • a java function calling a C++ function

  • a C++ function calling a java function

  • reproducible artifacts (thankfully aapt and bundletool do the Right Thing ™ by default)

  • use proguard for optimizing generated .class files

  • Use d8 and dx for creating .dex files

Notable missing features

Things that I am aware are missing, there are surely ton of other things too:

  • handling multiple manifest and resource files, which might be required when integrating external libraries

  • creating aar package and .pom file for consumption for Gradle projects

  • the library androidx seems to provide basic classes, for example class androidx.annotation.Keep. Maybe it would be better to have it packaged?

  • integration of R8, AFAIK it has not been packaged for Debian, similarly to bundletool

  • creating icons (for example from an .svg file) without Android Studio

  • downloading direct and transitive dependencies

  • encapsulate the CMake custom commands in functions and targets as reusable blocks to be used in projects

  • create in one go a package for multiple platforms (ie set ANDROID_ABI to arm64-v8a, x86, x86_64, armeabi, and armeabi-v7a)

It would be nice if there would be a way to ensure that all relevant .class file have been packaged. Since at the beginning I forgot to add the generated Kotlin file, and since the error only manifests itself at runtime, it would be much better if there would be at least a warning while building the application. There are some analyzers, but I did not try them out (yet).

Out of scope, but still important, would be some IDE integration. I’m not sure if Java-oriented IDE understand projects based on cmake, make, or ninja; which means that developing and debugging will be harder than necessary.

Another important thing is that some tools are not packaged (r8), and other are outdated (kotlin). While it is possible to download and install them by hand, managing packages is generally easier. Even if one wants to use containers for handling development environment, doing and apt install …​ inside a dockerfile is much easier that downloading and managing archives.

Full project

The full project can be downloaded as zip archive.

Here is an overview of the most relevant files

The native library:

src/hello.cpp
#include <cstring>
#include <jni.h>
#include <cassert>

#include <android/log.h>

namespace{
  void callback(JNIEnv* env) {
    assert(env);
    jclass clazz = env ? env->FindClass("com/example/hello/HelloFromCPP") : nullptr;
    assert(clazz != nullptr);
    jmethodID methodID = clazz ? env->GetStaticMethodID(clazz, "log", "()V") : nullptr;
    assert(methodID != nullptr);
    (methodID && clazz) ? env->CallStaticVoidMethod(clazz, methodID) : void();
  }
}

extern "C" {
  JNIEXPORT jstring Java_com_example_hello_HelloFromCPP_message( JNIEnv* env, jclass ) {
    __android_log_write( ANDROID_LOG_ERROR, "hello.cpp", "use __android_log_write for logging" );
    callback(env);
    return env->NewStringUTF("Hello from C++");
  }
}

The main activity; the entry point of this program:

src/com/example/MainActivity.java
package com.example;

import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;


import com.example.R;

import com.example.KotlinKt;
import com.example.hello.HelloFromCPP;

public class MainActivity extends Activity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    Log.e("cmakeapp", "fekir onCreate");
    super.onCreate(savedInstanceState);
    try{
      String s = this.getString(R.string.made_with_cmake);
      View view = LayoutInflater.from(this).inflate(R.layout.activity_main, null);
      TextView textView = view.findViewById(R.id.my_text);
      textView.setText(s + "\n "
                         + KotlinKt.message() + "\n "
                         + HelloFromCPP.message() + "\n "
      );
      setContentView(view);
    } catch (Exception ex) {
      TextView textView = new TextView(this);
      textView.setText( "an error occurred while creating the view:\n\n" + ex.getMessage());
      setContentView(textView);
    }
  }

  static String name() {
    return "MainActivity";
  }
}

The interface to the native code, also responsible for loading the shared library.

src/com/example/HelloFromCpp.java
package com.example.hello;

import android.util.Log;

public class HelloFromCPP {
  public native static String message();

  // used as callback from JNI code
  public static void log(){
    Log.e("cmakeapp", "callback from c++ code");
  }

  static {
    Log.e("cmakeapp", "loadLibrary hello");
    try{
    System.loadLibrary("hello");
      Log.e("cmakeapp", "libhello loaded");
    } catch (Throwable ex){
      Log.e("cmakeapp", "loadLibrary failed", ex);
      throw ex;
    }
  }
}

A Kotlin source file:

src/com/example/kotlin.kt
package com.example

import com.example.MainActivity;

fun message(): String {
  return "Hello from kotlin " + " for " + MainActivity.name();
}

The project file

CMakeLists.txt
cmake_minimum_required(VERSION 3.31)

project(AndroidApp)

set(CLASSPATH_SEPARATOR ":")
if(CMAKE_HOST_WIN32)
  set(CLASSPATH_SEPARATOR ";") # unfortunately not possible to use ; on all platforms
endif()

if(NOT ANDROID_SDK_ROOT)
  if($ENV{ANDROID_SDK_ROOT})
    set(ANDROID_SDK_ROOT $ENV{ANDROID_SDK_ROOT} CACHE PATH "Path to Android SDK")
  else()
    set(ANDROID_SDK_ROOT "/usr/lib/android-sdk/" CACHE PATH "Path to Android SDK")
  endif()
endif()

if(NOT BUILD_TOOLS)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/build-tools "${ANDROID_SDK_ROOT}/build-tools/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING)
  set(BUILD_TOOLS)
  foreach(child ${children})
    if(IS_DIRECTORY ${ANDROID_SDK_ROOT}/build-tools/${child})
      list(APPEND BUILD_TOOLS ${ANDROID_SDK_ROOT}/build-tools/${child})
    endif()
  endforeach()
endif()
find_program(AAPT      NAMES aapt              REQUIRED PATHS ${BUILD_TOOLS})
find_program(AAPT2     NAMES aapt2             REQUIRED PATHS ${BUILD_TOOLS})
find_program(APKSIGNER NAMES apksigner signapk REQUIRED PATHS ${BUILD_TOOLS})
find_program(ZIPALIGN  NAMES zipalign          REQUIRED PATHS ${BUILD_TOOLS})

execute_process(COMMAND "${ZIPALIGN}" OUTPUT_VARIABLE ZIPALIGN_OUTPUT ERROR_VARIABLE ZIPALIGN_OUTPUT OUTPUT_STRIP_TRAILING_WHITESPACE)
string(FIND "${ZIPALIGN_OUTPUT}" " -P " position)
set(ZIPALIGN_ALIGNEMNT_ARG "-p")
if(position GREATER -1)
  set(ZIPALIGN_ALIGNEMNT_ARG "-P" "16")
endif()
find_program(DX        NAMES dx        PATHS ${BUILD_TOOLS})
find_program(D8        NAMES d8        PATHS ${BUILD_TOOLS})
if(NOT DX AND NOT D8)
  message(FATAL_ERROR "At least dx or d8 is required, none found in ${BUILD_TOOLS}")
endif()
find_program(KOTLINC   NAMES kotlinc   REQUIRED)
set(KOTLIN_STDLIB "/usr/share/java/kotlin-stdlib.jar")
find_program(PROGUARD  NAMES proguard)

find_package(Java 1.8)
find_package(Java COMPONENTS Development)
include(UseJava) # java, jar, and other tools
set(BUNDLETOOL ${Java_JAVA_EXECUTABLE} -jar ${CMAKE_SOURCE_DIR}/bundletool-all-1.18.1.jar)

if(NOT ANDROID_PLATFORM_DIR)
  file(GLOB children RELATIVE ${ANDROID_SDK_ROOT}/platforms "${ANDROID_SDK_ROOT}/platforms/*")
  list(SORT children COMPARE NATURAL ORDER DESCENDING) # NOTE: also considers ${ANDROID_SDK_ROOT}/build-tools/debian
  list(GET children 0 ANDROID_PLATFORM_DIR)
  if(ANDROID_PLATFORM_DIR STRLESS ANDROID_PLATFORM)
    message(FATAL_ERROR "ERROR: no valid platform found. Found ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR}, but it is less than configured minimum supported version: ${ANDROID_PLATFORM}")
  endif()
  set(ANDROID_PLATFORM_DIR ${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM_DIR})
endif()
find_file(ANDROID_JAR
  NAMES android.jar
  PATHS ${ANDROID_PLATFORM_DIR} # with 36 get some warnings
  REQUIRED NO_CMAKE_FIND_ROOT_PATH # both missing from find_jar
)

add_executable(main src/main.cpp)


add_library(hello SHARED
  src/hello.cpp
)

target_link_libraries(hello
  PRIVATE
    log # required for logging with __android_log_write
)

set(CLASS_APP_FOLDER "${CMAKE_BINARY_DIR}/class_app_apk")



# PROJECT files
set(INPUTFILES_JAVA
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/MainActivity.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/Hello.java
  ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/hello/HelloFromCPP.java
)
set(INPUTFILES_KOTLIN ${CMAKE_CURRENT_SOURCE_DIR}/src/com/example/kotlin.kt)

set(BASE_CLASSPATH_FOR_JAVAC "${ANDROID_JAR}")
if(KOTLIN_STDLIB)
  # required at least for class kotlin.Metadata
  set(BASE_CLASSPATH_FOR_JAVAC "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${KOTLIN_STDLIB}")
endif()


set(STRINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/values/strings.xml")
set(IDS         "${CMAKE_CURRENT_SOURCE_DIR}/res/values/ids.xml")
set(LAYOUTS_XML "${CMAKE_CURRENT_SOURCE_DIR}/res/layout/activity_main.xml")
set(MANIFEST    "${CMAKE_CURRENT_SOURCE_DIR}/src/AndroidManifest.xml")


# create APK directly
set(FINAL_APK "${CMAKE_CURRENT_BINARY_DIR}/app.apk")

# FIXME: iterate like java files
set(RESOURCES "${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml")
add_custom_command(
  OUTPUT  ${RESOURCES}
  DEPENDS ${LAYOUTS_XML} ${STRINGS_XML}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/drawable/
  COMMAND ${CMAKE_COMMAND} -E copy ${LAYOUTS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/layout/activity_main.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${STRINGS_XML} ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/strings.xml
  COMMAND ${CMAKE_COMMAND} -E copy ${IDS}         ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk/values/ids.xml
  COMMENT "copy relevant resources to build folder"
  VERBATIM
)


set(CLASSPATH_GEN_APP ${CMAKE_CURRENT_BINARY_DIR}/gen_app_apk/)
set(PROGUARDRULES_D_APP ${CMAKE_CURRENT_BINARY_DIR}/proguard_rules/)
set(PROGUARDRULES_F_APP ${PROGUARDRULES_D_APP}/aapt.pro)
set(R_JAVA ${CLASSPATH_GEN_APP}/com/example/R.java)
add_custom_command(
  OUTPUT  ${R_JAVA} ${PROGUARDRULES_F_APP}
  DEPENDS ${MANIFEST} ${STRINGS_XML} ${LAYOUTS_XML} ${RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASSPATH_GEN_APP}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${PROGUARDRULES_D_APP}
  COMMAND ${AAPT} package -f -m -J ${CLASSPATH_GEN_APP} -S ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk -G ${PROGUARDRULES_F_APP} -M ${MANIFEST} -I ${ANDROID_JAR}
  COMMENT "create R.java file with aapt"
  VERBATIM
  CODEGEN
)

set(CLASS_FILES_KOTLIN ${CLASS_APP_FOLDER}/kotlin.jar)
add_custom_command(
  OUTPUT  ${CLASS_FILES_KOTLIN}
  DEPENDS ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASS_APP_FOLDER} # not required when not creating jar file
  COMMAND ${KOTLINC} -jvm-target 1.8 -classpath "${ANDROID_JAR}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_APP}" -d ${CLASS_FILES_KOTLIN} ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA};
  COMMENT "kotlinc"
  VERBATIM
)

# java might depend on .class files generated by kotlin
set(CLASS_FILES_JAVA ${CLASS_APP_FOLDER}/java.jar)
add_custom_command(
  OUTPUT  ${CLASS_FILES_JAVA}
  DEPENDS ${INPUTFILES_JAVA} ${R_JAVA} ${CLASS_FILES_KOTLIN}
  COMMAND  ${Java_JAVAC_EXECUTABLE} -Xlint:all --class-path "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_APP}" --source 8 --target 8 -d ${CLASS_APP_FOLDER}/java ${INPUTFILES_JAVA};
  COMMAND  ${Java_JAR_EXECUTABLE} --date "1980-01-01T12:01:00Z" --create --no-manifest --file ${CLASS_FILES_JAVA} -C ${CLASS_APP_FOLDER}/java .;
  COMMENT "javac + jar"
  VERBATIM
)

if(PROGUARD)
  set(CLASSPATH_FOR_PROGUARD ${ANDROID_JAR})
  if(CLASS_FILES_KOTLIN)
    # required at least for class kotlin.Metadata
    set(CLASSPATH_FOR_PROGUARD "${CLASSPATH_FOR_PROGUARD}${CLASSPATH_SEPARATOR}/usr/share/java/kotlin-stdlib.jar")
  endif()
  set(OPTIMIZED_CLASS ${CLASS_APP_FOLDER}/optimized.jar)
  add_custom_command(
    OUTPUT  ${OPTIMIZED_CLASS}
    DEPENDS ${CLASS_FILES_KOTLIN} ${CLASS_FILES_JAVA}
    COMMAND ${PROGUARD} -dontobfuscate -libraryjars ${CLASSPATH_FOR_PROGUARD}
                        -injars ${CLASS_FILES_JAVA}${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN}
                        -outjars ${OPTIMIZED_CLASS}
                        -dontwarn org.jetbrains.annotations.NotNull # used only for static analysis, safe to ignore, no need to add other jars to classpath
                        # should be user-supplied; callback for jni
                        -keep "public class com.example.hello.HelloFromCPP{public static void log();}"
                        -include ${PROGUARDRULES_F_APP} # references com.example.MainActivity
    COMMENT "optimize .class files"
    VERBATIM
  )
  set(CLASS_FILES ${OPTIMIZED_CLASS})
else()
  set(CLASS_FILES ${CLASS_FILES_KOTLIN} ${CLASS_FILES_JAVA})
endif()

# convert .class to dex; note that DX is not part of never apks
set(DEX_FILE ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/classes.dex)
if(D8) # prefer d8 to dx if available
  add_custom_command(
    OUTPUT  ${DEX_FILE}
    DEPENDS ${MANIFEST} ${CLASS_FILES}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk
    COMMAND ${D8} --output ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/ --classpath ${ANDROID_JAR} ${CLASS_FILES}
    COMMENT "convert .class to .dex file (d8)"
    VERBATIM
  )
else()
  add_custom_command(
    OUTPUT  ${DEX_FILE}
    DEPENDS ${MANIFEST} ${CLASS_FILES}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk
    COMMAND ${DX} --dex --min-sdk-version=23 --output=${DEX_FILE} ${CLASS_FILES}
    COMMENT "convert .class to .dex file (dx)"
    VERBATIM
  )
endif()

set(UNSIGNED_APK "${CMAKE_CURRENT_BINARY_DIR}/app.unsigned.apk")
add_custom_command(
  OUTPUT  ${UNSIGNED_APK}
  DEPENDS ${MANIFEST} ${DEX_FILE} hello ${RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/lib/${ANDROID_ABI}
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_BINARY_DIR}/libhello.so ${CMAKE_CURRENT_BINARY_DIR}/base_app_apk/lib/${ANDROID_ABI}
  COMMAND ${AAPT} package -f -F ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk -I ${ANDROID_JAR} -M ${MANIFEST} -S ${CMAKE_CURRENT_BINARY_DIR}/res_app_apk base_app_apk
  COMMAND ${ZIPALIGN} ${ZIPALIGN_ALIGNEMNT_ARG} -f 4 ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk ${UNSIGNED_APK}
  COMMAND ${CMAKE_COMMAND} -E remove ${CMAKE_CURRENT_BINARY_DIR}/app.unaligned.apk
  COMMENT "create unsigned apk"
  VERBATIM
)

set(KEYSTORE "${CMAKE_CURRENT_SOURCE_DIR}/keystore.jks")
add_custom_command(
  OUTPUT  ${FINAL_APK}
  DEPENDS ${UNSIGNED_APK}
  COMMAND ${ZIPALIGN} ${ZIPALIGN_ALIGNEMNT_ARG} -f 4 ${UNSIGNED_APK} ${FINAL_APK}
  COMMAND ${APKSIGNER} sign --in ${FINAL_APK} -ks ${KEYSTORE} --ks-key-alias androidkey --ks-pass pass:android --key-pass pass:android --min-sdk-version 21 --v2-signing-enabled true --v1-signing-enabled false
  COMMENT "create final signed apk"
  VERBATIM
)

# generate bundle

set(FLAT_RESOURCES ${CMAKE_BINARY_DIR}/flat_res/layout_activity_main.xml.flat ${CMAKE_BINARY_DIR}/flat_res/values_strings.arsc.flat ${CMAKE_BINARY_DIR}/flat_res/values_ids.arsc.flat)
add_custom_command(
  OUTPUT  ${FLAT_RESOURCES}
  DEPENDS ${STRINGS_XML} ${IDS} ${LAYOUTS_XML}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/flat_res # otherwise aapt2 creates zip archive
  COMMAND ${AAPT2} compile -o ${CMAKE_BINARY_DIR}/flat_res/ ${STRINGS_XML} ${IDS} ${LAYOUTS_XML}
  COMMENT "compile resources to flat"
  VERBATIM
)

set(CLASSPATH_GEN_BUNDLE ${CMAKE_CURRENT_BINARY_DIR}/gen_app_bundle/)
set(R_JAVA_BUNDLE ${CLASSPATH_GEN_BUNDLE}/com/example/R.java)


set(PROGUARDRULES_D_BUNDLE ${CMAKE_CURRENT_BINARY_DIR}/proguard_rules_bundle/)
set(PROGUARDRULES_F_BUNDLE ${PROGUARDRULES_D_BUNDLE}/aapt.pro)

add_custom_command(
  OUTPUT  ${R_JAVA_BUNDLE} ${PROGUARDRULES_F_BUNDLE}
  DEPENDS ${FLAT_RESOURCES}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/output_ # otherwise aapt2 link errors
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASSPATH_GEN_BUNDLE}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${PROGUARDRULES_D_BUNDLE}
  COMMAND ${AAPT2} link --proto-format -o ${CMAKE_BINARY_DIR}/output_
                        -I ${ANDROID_JAR}
                        --manifest ${MANIFEST}
                        -R ${FLAT_RESOURCES}
                        --output-to-dir
                        --java ${CLASSPATH_GEN_BUNDLE}/
                        --proguard ${PROGUARDRULES_F_BUNDLE}
  COMMENT "aapt2 link and create R.java"
  VERBATIM
  CODEGEN
)


set(CLASS_BUNDLE_FOLDER "${CMAKE_BINARY_DIR}/class_app_bundle")

set(CLASS_FILES_KOTLIN_BUNDLE "${CLASS_BUNDLE_FOLDER}/kotlin.jar")
add_custom_command(
  OUTPUT  ${CLASS_FILES_KOTLIN_BUNDLE}
  DEPENDS ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA_BUNDLE}
  COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASS_BUNDLE_FOLDER} # not required when not creating jar file
  COMMAND ${KOTLINC} -jvm-target 1.8 -classpath "${ANDROID_JAR}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_BUNDLE}" -d ${CLASS_FILES_KOTLIN_BUNDLE} ${INPUTFILES_KOTLIN} ${INPUTFILES_JAVA} ${R_JAVA_BUNDLE};
  COMMENT "kotlinc (bundle)"
  VERBATIM
)

set(CLASS_FILES_JAVA_BUNDLE "${CLASS_BUNDLE_FOLDER}/java.jar")
add_custom_command(
  OUTPUT  ${CLASS_FILES_JAVA_BUNDLE}
  DEPENDS ${INPUTFILES_JAVA} ${R_JAVA_BUNDLE} ${CLASS_FILES_KOTLIN_BUNDLE}
  COMMAND  ${Java_JAVAC_EXECUTABLE} -Xlint:all --class-path "${BASE_CLASSPATH_FOR_JAVAC}${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN_BUNDLE}${CLASSPATH_SEPARATOR}${CLASSPATH_GEN_BUNDLE}" --source 8 --target 8 -d ${CLASS_BUNDLE_FOLDER}/java ${INPUTFILES_JAVA};
  COMMAND  ${Java_JAR_EXECUTABLE} --create --no-manifest --file ${CLASS_FILES_JAVA_BUNDLE} -C ${CLASS_BUNDLE_FOLDER}/java .;
  COMMENT "javac + jar (bundle)"
  VERBATIM
)


if(PROGUARD)
  set(CLASSPATH_FOR_PROGUARD ${ANDROID_JAR})
  if(KOTLIN_STDLIB)
    # required at least for class kotlin.Metadata
    set(CLASSPATH_FOR_PROGUARD "${CLASSPATH_FOR_PROGUARD}${CLASSPATH_SEPARATOR}${KOTLIN_STDLIB}")
  endif()
  set(OPTIMIZED_CLASS_BUNDLE ${CLASS_BUNDLE_FOLDER}/optimized.jar)
  add_custom_command(
    OUTPUT  ${OPTIMIZED_CLASS_BUNDLE}
    DEPENDS ${CLASS_FILES_KOTLIN_BUNDLE} ${CLASS_FILES_JAVA_BUNDLE}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CLASS_BUNDLE_FOLDER}/optimized/
    COMMAND ${PROGUARD} -dontobfuscate -libraryjars ${CLASSPATH_FOR_PROGUARD}
                        -injars ${CLASS_FILES_JAVA_BUNDLE}/${CLASSPATH_SEPARATOR}${CLASS_FILES_KOTLIN_BUNDLE}
                        -outjars ${OPTIMIZED_CLASS_BUNDLE}
                        -dontwarn org.jetbrains.annotations.NotNull # used only for static analysis, safe to ignore, no need to add other jars to classpath
                        # should be user-supplied; callback for jni
                        -keep "public class com.example.hello.HelloFromCPP{public static void log();}"
                        -include ${PROGUARDRULES_F_BUNDLE} # references com.example.MainActivity
    COMMENT "optimize .class files (bundle)"
    VERBATIM
  )
  set(CLASS_FILES_BUNDLE ${OPTIMIZED_CLASS_BUNDLE})
else()
  set(CLASS_FILES_BUNDLE ${CLASS_FILES_KOTLIN_BUNDLE} ${CLASS_FILES_JAVA_BUNDLE})
endif()

# convert .class to dex
set(DEX_FILE_BUNDLE ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle/classes.dex)
if(D8)
  add_custom_command(
    OUTPUT  ${DEX_FILE_BUNDLE}
    DEPENDS ${MANIFEST} ${CLASS_FILES_BUNDLE}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle
    COMMAND ${D8} --output ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle/ --classpath ${ANDROID_JAR} ${CLASS_FILES_BUNDLE}
    COMMENT "convert .class to .dex file (d8)"
    VERBATIM
  )
else()
  add_custom_command(
    OUTPUT  ${DEX_FILE_BUNDLE}
    DEPENDS ${MANIFEST} ${CLASS_FILES_BUNDLE}
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_app_bundle
    COMMAND ${DX} --dex --min-sdk-version=23 --output=${DEX_FILE_BUNDLE} ${CLASS_FILES_BUNDLE}
    COMMENT "convert .class to .dex file (bundle)"
    VERBATIM
  )
endif()






add_custom_command(
  OUTPUT  ${CMAKE_BINARY_DIR}/base_for_bundle/resources.pb
  DEPENDS ${DEX_FILE_BUNDLE} hello

  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/base_for_bundle

  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_BINARY_DIR}/output_/resources.pb ${CMAKE_BINARY_DIR}/base_for_bundle/resources.pb


  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/base_for_bundle/manifest
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_BINARY_DIR}/output_/AndroidManifest.xml ${CMAKE_BINARY_DIR}/base_for_bundle/manifest/AndroidManifest.xml

  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/base_for_bundle/dex
  COMMAND ${CMAKE_COMMAND} -E copy ${DEX_FILE_BUNDLE} ${CMAKE_BINARY_DIR}/base_for_bundle/dex/classes.dex


  COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_BINARY_DIR}/output_/res ${CMAKE_BINARY_DIR}/base_for_bundle/res

  COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/base_for_bundle/lib/${ANDROID_ABI}
  COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_BINARY_DIR}/libhello.so ${CMAKE_CURRENT_BINARY_DIR}/base_for_bundle/lib/${ANDROID_ABI}

  #COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_BINARY_DIR}/dex/lib ${CMAKE_BINARY_DIR}/base_for_bundle/lib
  # roots
  # assets
  #COMMAND ${CMAKE_COMMAND} -E tar "cf" ${CMAKE_BINARY_DIR}/base.zip --format=zip -- ${CMAKE_BINARY_DIR}/base_for_bundle

  COMMENT "collect resources for bundle"
  VERBATIM
)


set(BASE_ZIP "${CMAKE_CURRENT_BINARY_DIR}/base.zip" )
add_custom_command( # separate command because of WORKING_DIRECTORY
  OUTPUT  ${BASE_ZIP}
  DEPENDS ${CMAKE_BINARY_DIR}/base_for_bundle/resources.pb
  #COMMAND ${CMAKE_COMMAND} -E tar "cf" ${CMAKE_BINARY_DIR}/base.zip --format=zip -- dex lib manifest res
  COMMAND ${CMAKE_COMMAND} -E tar "cf" ${CMAKE_BINARY_DIR}/base.zip --format=zip -- .
  WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/base_for_bundle/
  COMMENT "create base.zip"
  VERBATIM
)

set(FINAL_BUNDLE "${CMAKE_CURRENT_BINARY_DIR}/app.aab")
add_custom_command(
  OUTPUT  ${FINAL_BUNDLE}
  DEPENDS ${BASE_ZIP}

  COMMAND ${BUNDLETOOL} build-bundle --modules=${BASE_ZIP} --output=${FINAL_BUNDLE}
  COMMAND ${BUNDLETOOL} build-apks --bundle=${FINAL_BUNDLE}
                                   --output=${CMAKE_CURRENT_BINARY_DIR}/app.apks
                                   --mode=universal
                                   --ks=${KEYSTORE}
                                   --ks-pass pass:android
                                   --ks-key-alias=androidkey
                                   --key-pass pass:android
  COMMAND ${CMAKE_COMMAND} -E tar x ${CMAKE_CURRENT_BINARY_DIR}/app.apks universal.apk
  COMMENT "create final signed aab+apk"
  VERBATIM
)

add_custom_target(apk ALL DEPENDS ${FINAL_APK})
add_custom_target(aab ALL DEPENDS ${FINAL_BUNDLE})

Last but not least, the resource files and manifest.

Note that this manifest also depends on the resource (the resource @string/app_name):

src/Manifest
<?xml version="1.0" encoding="utf-8"?>
<manifest
  xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.example"
  android:versionCode="1"
  android:versionName="1.0">
  <!-- FIXME: those values should be determined by the build system -->
  <uses-sdk
    android:minSdkVersion="23"
    android:targetSdkVersion="35"
    maxSdkVersion="35"
  />
  <application
    android:label="@string/app_name"
    android:debuggable="true"
    android:allowBackup="true"
  >
    <activity android:name=".MainActivity" android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
    </activity>
  </application>
</manifest>
res/values/strings.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">CMakeApp</string>
  <string name="made_with_cmake">Made with CMake</string>
</resources>
activity.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:gravity="center"
  android:orientation="vertical"
>
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:id="@+id/my_text"
  />
</LinearLayout>

If you have questions, comments, or found typos, the notes are not clear, or there are some errors; then just contact me.