diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abe43dcd..e6730749 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,16 @@ jobs: - name: Build Bevy APK working-directory: examples/bevy-2d run: crossbundle build android --release + - name: Build Bevy GameActivity APK + working-directory: examples/bevy-game-activity + run: | + crossbundle build android --release + manifest="$GITHUB_WORKSPACE/target/android/bevy-game-activity/gradle/AndroidManifest.xml" + apk="$GITHUB_WORKSPACE/target/android/bevy-game-activity/gradle/build/outputs/apk/release/gradle-release-unsigned.apk" + grep -q 'com.google.androidgamesdk.GameActivity' "$manifest" + grep -q 'android.app.lib_name' "$manifest" + grep -q 'Theme.AppCompat.Light.NoActionBar' "$manifest" + unzip -l "$apk" 'lib/arm64-v8a/libbevy_game_activity.so' android-build-windows: name: Build Android example on Windows diff --git a/.github/workflows/latest-dependencies.yml b/.github/workflows/latest-dependencies.yml index 40a7f08f..0129be4f 100644 --- a/.github/workflows/latest-dependencies.yml +++ b/.github/workflows/latest-dependencies.yml @@ -99,6 +99,9 @@ jobs: run: >- cargo test --workspace --all-targets --exclude crossbow-ios --locked --no-fail-fast + - name: Build Bevy GameActivity APK against the fresh resolution + working-directory: examples/bevy-game-activity + run: cargo run -p crossbundle --locked -- build android --release - name: Save the generated lockfile if: always() uses: actions/upload-artifact@v7 diff --git a/Cargo.lock b/Cargo.lock index cfe72c89..9a51bc92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,7 @@ dependencies = [ "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", + "simd_cesu8", "thiserror 2.0.20", ] @@ -485,6 +486,13 @@ dependencies = [ "log", ] +[[package]] +name = "bevy-game-activity" +version = "0.2.3" +dependencies = [ + "bevy", +] + [[package]] name = "bevy_a11y" version = "0.19.1" diff --git a/crossbundle/cli/README.md b/crossbundle/cli/README.md index b6adddd2..cf9a3025 100644 --- a/crossbundle/cli/README.md +++ b/crossbundle/cli/README.md @@ -59,7 +59,7 @@ assets = ["assets"] icon = "../../assets/images/icon.png" [package.metadata.android] -# Optional Android activity integration: native-activity (default) or miniquad. +# Optional Android activity integration: native-activity (default), game-activity, or miniquad. runtime = "native-activity" # Path to AndroidManifest.xml file manifest_path = "path/to/AndroidManifest.xml" diff --git a/crossbundle/cli/src/commands/build/android.rs b/crossbundle/cli/src/commands/build/android.rs index a9ca8718..fa2217ee 100644 --- a/crossbundle/cli/src/commands/build/android.rs +++ b/crossbundle/cli/src/commands/build/android.rs @@ -1,4 +1,4 @@ -use super::{BuildContext, SharedBuildCommand}; +use super::{BuildContext, SharedBuildCommand, validate_android_activity_runtime}; use crate::{error::*, types::ProjectConfig}; use android_manifest::AndroidManifest; use android_tools::java_tools::Key; @@ -53,6 +53,14 @@ impl AndroidBuildCommand { /// Builds the application with the selected Android strategy. pub fn run(&self, config: &CliContext) -> Result<()> { let context = BuildContext::new(config, &self.shared)?; + for target in Self::android_build_targets(&context, self.shared.profile(), &self.target) { + validate_android_activity_runtime( + &context.project, + &context.project_config, + &self.shared, + target.rust_triple(), + )?; + } let plan = self.create_plan( &context, crossbundle_tools::toolchain::PlanOperation::Build, diff --git a/crossbundle/cli/src/commands/build/build_context.rs b/crossbundle/cli/src/commands/build/build_context.rs index c378706a..15336ad1 100644 --- a/crossbundle/cli/src/commands/build/build_context.rs +++ b/crossbundle/cli/src/commands/build/build_context.rs @@ -1,5 +1,7 @@ use super::SharedBuildCommand; use crate::{error::*, types::*}; +#[cfg(feature = "android")] +use crossbundle_tools::types::AndroidRuntime; use crossbundle_tools::{ commands::*, types::{CliContext, parse_project_config}, @@ -46,3 +48,100 @@ impl BuildContext { }) } } + +#[cfg(feature = "android")] +pub(super) fn validate_android_activity_runtime( + project: &CargoProject, + config: &ProjectConfig, + command: &SharedBuildCommand, + target: &str, +) -> Result<()> { + let runtime = config.android.runtime; + if runtime == AndroidRuntime::Miniquad { + return Ok(()); + } + let Some(features) = project.target_dependency_features( + "android-activity", + target, + &command.features, + command.all_features, + command.no_default_features, + )? + else { + // Custom runtimes may implement the native Activity ABI without android-activity. + return Ok(()); + }; + validate_android_activity_features(runtime, &features) +} + +#[cfg(feature = "android")] +fn validate_android_activity_features(runtime: AndroidRuntime, features: &[String]) -> Result<()> { + let expected = match runtime { + AndroidRuntime::NativeActivity => "native-activity", + AndroidRuntime::GameActivity => "game-activity", + AndroidRuntime::Miniquad => return Ok(()), + }; + if features.iter().any(|feature| feature == expected) + && !features.iter().any(|feature| { + matches!(feature.as_str(), "native-activity" | "game-activity") && feature != expected + }) + { + return Ok(()); + } + + Err(anyhow::anyhow!( + "Android runtime `{}` requires the resolved `android-activity` feature `{expected}`, but its activated features are [{}]. Align `package.metadata.android.runtime` with the Bevy or android-activity Android feature.", + runtime.as_str(), + features.join(", ") + ) + .into()) +} + +#[cfg(all(test, feature = "android"))] +mod tests { + use super::*; + + fn features(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn accepts_matching_android_activity_runtime_features() { + validate_android_activity_features( + AndroidRuntime::NativeActivity, + &features(&["native-activity"]), + ) + .unwrap(); + validate_android_activity_features( + AndroidRuntime::GameActivity, + &features(&["game-activity"]), + ) + .unwrap(); + validate_android_activity_features( + AndroidRuntime::Miniquad, + &features(&["native-activity", "game-activity"]), + ) + .unwrap(); + } + + #[test] + fn rejects_mismatched_or_ambiguous_android_activity_runtime_features() { + let mismatch = validate_android_activity_features( + AndroidRuntime::GameActivity, + &features(&["native-activity"]), + ) + .unwrap_err() + .to_string(); + assert!( + mismatch.contains("requires the resolved `android-activity` feature `game-activity`") + ); + + let ambiguous = validate_android_activity_features( + AndroidRuntime::GameActivity, + &features(&["native-activity", "game-activity"]), + ) + .unwrap_err() + .to_string(); + assert!(ambiguous.contains("native-activity, game-activity")); + } +} diff --git a/crossbundle/tools/Cargo.toml b/crossbundle/tools/Cargo.toml index 043f1661..93e329cd 100644 --- a/crossbundle/tools/Cargo.toml +++ b/crossbundle/tools/Cargo.toml @@ -39,6 +39,8 @@ androidx-appcompat.preferred = "1.8.0" androidx-appcompat.supported = ">=1.8, <2" androidx-core.preferred = "1.13.0" androidx-core.supported = ">=1.13, <2" +androidx-games-activity.preferred = "4.4.0" +androidx-games-activity.supported = ">=4.4, <4.5" play-billing.preferred = "9.1.0" play-billing.supported = ">=9.1, <10" play-games-services.preferred = "21.0.0" diff --git a/crossbundle/tools/src/commands/android/gradle/gen_gradle_project.rs b/crossbundle/tools/src/commands/android/gradle/gen_gradle_project.rs index 17702ca2..13a576a5 100644 --- a/crossbundle/tools/src/commands/android/gradle/gen_gradle_project.rs +++ b/crossbundle/tools/src/commands/android/gradle/gen_gradle_project.rs @@ -38,8 +38,11 @@ pub fn gen_gradle_project( std::fs::write(file_path, file.data.as_ref())?; } + let crossbow_app = gradle_project_path.join("src/com/crossbow/game/CrossbowApp.kt"); if runtime == AndroidRuntime::Miniquad || !crossbow_bridge { - std::fs::remove_file(gradle_project_path.join("src/com/crossbow/game/CrossbowApp.kt"))?; + std::fs::remove_file(&crossbow_app)?; + } else { + std::fs::write(&crossbow_app, crossbow_app_activity(runtime))?; } if runtime == AndroidRuntime::Miniquad { install_miniquad_runtime( @@ -60,6 +63,7 @@ pub fn gen_gradle_project( sdk_versions, plugins, crossbow_bridge, + runtime, ), )?; @@ -88,6 +92,22 @@ pub fn gen_gradle_project( Ok(gradle_project_path) } +fn crossbow_app_activity(runtime: AndroidRuntime) -> String { + let base = match runtime { + AndroidRuntime::NativeActivity => "CrossbowNativeActivity", + AndroidRuntime::GameActivity => "CrossbowGameActivity", + AndroidRuntime::Miniquad => unreachable!("Miniquad generates its own application wrapper"), + }; + format!( + r#"package com.crossbow.game + +import com.crossbow.library.{base} + +class CrossbowApp : {base}() +"# + ) +} + fn install_miniquad_runtime( gradle_project_path: &Path, package_name: &str, @@ -158,33 +178,23 @@ package {package_name} import android.content.Intent import android.os.Bundle +import android.view.ViewGroup import com.crossbow.library.Crossbow -import com.crossbow.library.CrossbowHost import com.crossbow.library.CrossbowLib -open class CrossbowApp : MainActivity(), CrossbowHost {{ - private var crossbow: Crossbow? = null +open class CrossbowApp : MainActivity() {{ + private lateinit var crossbow: Crossbow override fun onCreate(savedInstanceState: Bundle?) {{ CrossbowLib.initializeAndroidContext(this) super.onCreate(savedInstanceState) - crossbow = if (savedInstanceState == null) {{ - Crossbow().also {{ - fragmentManager.beginTransaction().add(android.R.id.content, it).commit() - }} - }} else {{ - fragmentManager.findFragmentById(android.R.id.content) as? Crossbow - }} - }} - - override fun onNewIntent(intent: Intent) {{ - super.onNewIntent(intent) - crossbow?.onNewIntent(intent) + crossbow = Crossbow(this) + findViewById(android.R.id.content).addView(crossbow.view) }} override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {{ super.onActivityResult(requestCode, resultCode, data) - crossbow?.onActivityResult(requestCode, resultCode, data) + crossbow.onActivityResult(requestCode, resultCode, data) }} override fun onRequestPermissionsResult( @@ -193,16 +203,27 @@ open class CrossbowApp : MainActivity(), CrossbowHost {{ grantResults: IntArray ) {{ super.onRequestPermissionsResult(requestCode, permissions, grantResults) - crossbow?.onRequestPermissionsResult(requestCode, permissions, grantResults) + crossbow.onRequestPermissionsResult(requestCode, permissions, grantResults) }} override fun onBackPressed() {{ - crossbow?.onBackPressed() ?: super.onBackPressed() + if (!crossbow.onBackPressed()) super.onBackPressed() + }} + + override fun onPause() {{ + crossbow.onPause() + super.onPause() + }} + + override fun onResume() {{ + super.onResume() + if (::crossbow.isInitialized) crossbow.onResume() }} override fun onDestroy() {{ - super.onDestroy() + if (::crossbow.isInitialized) crossbow.onDestroy() CrossbowLib.releaseAndroidContext() + super.onDestroy() }} }} "# @@ -236,10 +257,12 @@ fn get_gradle_properties( sdk_versions: AndroidSdkVersions, plugins: &AndroidGradlePlugins, crossbow_bridge: bool, + runtime: AndroidRuntime, ) -> String { let mut result = get_default_gradle_props(package_name, version_code, version_name, sdk_versions); result.push_str(&format!("crossbow_bridge={crossbow_bridge}\n")); + result.push_str(&format!("crossbow_android_runtime={}\n", runtime.as_str())); if !plugins.maven_repos.is_empty() { result.push_str(&format!( "plugins_maven_repos={}\n", @@ -385,30 +408,57 @@ mod tests { local_projects: vec![], }; assert_eq!( - get_gradle_properties("com.crossbow.test", 1, "1.0", sdk_versions, &plugins, true,), + get_gradle_properties( + "com.crossbow.test", + 1, + "1.0", + sdk_versions, + &plugins, + true, + AndroidRuntime::GameActivity, + ), format!( - "{}crossbow_bridge=true\n", + "{}crossbow_bridge=true\ncrossbow_android_runtime=game-activity\n", get_default_gradle_props("com.crossbow.test", 1, "1.0", sdk_versions) ), ); plugins.local.push(PathBuf::from("../../MyPlugin.aar")); assert_eq!( - get_gradle_properties("com.crossbow.test", 1, "1.0", sdk_versions, &plugins, true,), + get_gradle_properties( + "com.crossbow.test", + 1, + "1.0", + sdk_versions, + &plugins, + true, + AndroidRuntime::GameActivity, + ), format!( "{}{}{}", get_default_gradle_props("com.crossbow.test", 1, "1.0", sdk_versions), - "crossbow_bridge=true\n", + "crossbow_bridge=true\ncrossbow_android_runtime=game-activity\n", "plugins_local_binaries=../../MyPlugin.aar\n" ) ); } + #[test] + fn game_activity_wrapper_selects_the_crossbow_game_host() { + let source = crossbow_app_activity(AndroidRuntime::GameActivity); + assert!(source.contains("import com.crossbow.library.CrossbowGameActivity")); + assert!(source.contains("class CrossbowApp : CrossbowGameActivity()")); + + let source = crossbow_app_activity(AndroidRuntime::NativeActivity); + assert!(source.contains("class CrossbowApp : CrossbowNativeActivity()")); + } + #[test] fn miniquad_bridge_uses_the_application_package() { let source = miniquad_crossbow_activity("dev.crossbow.game"); assert!(source.contains("package dev.crossbow.game")); - assert!(source.contains("class CrossbowApp : MainActivity(), CrossbowHost")); + assert!(source.contains("class CrossbowApp : MainActivity()")); + assert!(!source.contains("Fragment")); assert!(source.contains("onRequestPermissionsResult")); } diff --git a/crossbundle/tools/src/commands/common/cargo_project.rs b/crossbundle/tools/src/commands/common/cargo_project.rs index 5815954e..d88dbebe 100644 --- a/crossbundle/tools/src/commands/common/cargo_project.rs +++ b/crossbundle/tools/src/commands/common/cargo_project.rs @@ -2,7 +2,7 @@ use crate::error::*; use crate::types::{CargoTargetSelection, is_library_kind}; use serde::Deserialize; use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, path::{Path, PathBuf}, process::Command, }; @@ -152,26 +152,11 @@ impl CargoProject { if !dependencies { command.arg("--no-deps"); } - if !features.is_empty() { - command.arg("--features").arg(features.join(",")); - } - if all_features { - command.arg("--all-features"); - } - if no_default_features { - command.arg("--no-default-features"); - } + add_feature_args(&mut command, features, all_features, no_default_features); if let Some(project_dir) = manifest_path.parent() { command.current_dir(project_dir); } - let output = command.output()?; - if !output.status.success() { - return Err(Error::CmdFailed( - command, - String::from_utf8_lossy(&output.stdout).into_owned(), - String::from_utf8_lossy(&output.stderr).into_owned(), - )); - } + let output = command.output_err(false)?; let metadata: Metadata = serde_json::from_slice(&output.stdout) .map_err(|error| anyhow::anyhow!("invalid output from `cargo metadata`: {error}"))?; Self::from_metadata(manifest_path, metadata) @@ -190,16 +175,12 @@ impl CargoProject { .into_iter() .map(|package| (package.id.clone(), package)) .collect(); - let dependencies = metadata - .resolve - .map(|resolve| { - resolve - .nodes - .into_iter() - .map(|node| (node.id, node.dependencies)) - .collect() - }) - .unwrap_or_default(); + let mut dependencies = HashMap::new(); + if let Some(resolve) = metadata.resolve { + for node in resolve.nodes { + dependencies.insert(node.id, node.dependencies); + } + } Ok(Self { workspace_manifest_path: metadata.workspace_root.join("Cargo.toml"), target_directory: metadata.target_directory, @@ -287,6 +268,82 @@ impl CargoProject { /// Find one named package in the selected package's resolved dependency closure. pub fn dependency(&self, name: &str) -> Result<&CargoPackage> { + let matches = self.dependencies_named(name); + match matches.as_slice() { + [package] => Ok(package), + [] => Err(anyhow::anyhow!( + "Cargo package `{}` does not depend on `{name}`", + self.package.name + ) + .into()), + packages => Err(ambiguous_dependency(&self.package.name, name, packages).into()), + } + } + + /// Return target-specific activated features for one dependency of the selected package. + pub fn target_dependency_features( + &self, + name: &str, + target: &str, + selected_features: &[String], + all_features: bool, + no_default_features: bool, + ) -> Result>> { + let mut command = Command::new("cargo"); + command + .arg("tree") + .arg("--manifest-path") + .arg(&self.package.manifest_path) + .arg("--package") + .arg(&self.package.name) + .arg("--target") + .arg(target) + .args([ + "--edges", "normal", "--prefix", "none", "--format", "{p}|{f}", + ]); + add_feature_args( + &mut command, + selected_features, + all_features, + no_default_features, + ); + if let Some(project_dir) = self.package.manifest_path.parent() { + command.current_dir(project_dir); + } + let output = command.output_err(false)?; + + let package_prefix = format!("{name} v"); + let mut matches = BTreeMap::<&str, Vec>::new(); + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let Some((package, features)) = line.split_once('|') else { + continue; + }; + if package.starts_with(&package_prefix) { + matches.entry(package).or_default().extend( + features + .split(',') + .filter(|feature| !feature.is_empty()) + .map(str::to_owned), + ); + } + } + let Some((_, mut activated)) = matches.pop_first() else { + return Ok(None); + }; + if !matches.is_empty() { + return Err(anyhow::anyhow!( + "Cargo package `{}` resolves multiple target-specific `{name}` versions", + self.package.name + ) + .into()); + } + activated.sort(); + activated.dedup(); + Ok(Some(activated)) + } + + fn dependencies_named(&self, name: &str) -> Vec<&CargoPackage> { let mut pending = vec![self.package.id.as_str()]; let mut visited = HashSet::new(); let mut matches = Vec::new(); @@ -309,24 +366,35 @@ impl CargoProject { ); } matches.sort_by(|left, right| left.version.cmp(&right.version)); - match matches.as_slice() { - [package] => Ok(package), - [] => Err(anyhow::anyhow!( - "Cargo package `{}` does not depend on `{name}`", - self.package.name - ) - .into()), - packages => Err(anyhow::anyhow!( - "Cargo package `{}` resolves multiple `{name}` versions: {}", - self.package.name, - packages - .iter() - .map(|package| package.version.as_str()) - .collect::>() - .join(", ") - ) - .into()), - } + matches + } +} + +fn ambiguous_dependency(package: &str, name: &str, matches: &[&CargoPackage]) -> anyhow::Error { + anyhow::anyhow!( + "Cargo package `{package}` resolves multiple `{name}` versions: {}", + matches + .iter() + .map(|package| package.version.as_str()) + .collect::>() + .join(", ") + ) +} + +fn add_feature_args( + command: &mut Command, + features: &[String], + all_features: bool, + no_default_features: bool, +) { + if !features.is_empty() { + command.arg("--features").arg(features.join(",")); + } + if all_features { + command.arg("--all-features"); + } + if no_default_features { + command.arg("--no-default-features"); } } @@ -435,14 +503,15 @@ mod tests { app.join("Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\ [lib]\nname = \"mobile_app\"\ncrate-type = [\"cdylib\", \"rlib\"]\n\ - [features]\nmobile = [\"dep:miniquad\"]\n\ + [features]\nmobile = [\"dep:miniquad\", \"miniquad/runtime\"]\n\ [dependencies]\nminiquad = { path = \"../miniquad\", optional = true }\n", ) .unwrap(); std::fs::write(app.join("src/lib.rs"), "").unwrap(); std::fs::write( dependency.join("Cargo.toml"), - "[package]\nname = \"miniquad\"\nversion = \"1.2.3\"\nedition = \"2024\"\n", + "[package]\nname = \"miniquad\"\nversion = \"1.2.3\"\nedition = \"2024\"\n\ + [features]\nruntime = []\n", ) .unwrap(); std::fs::write(dependency.join("src/lib.rs"), "").unwrap(); @@ -456,6 +525,24 @@ mod tests { assert_eq!(project.library_target().unwrap().name, "mobile_app"); assert!(project.library_target().unwrap().is_cdylib()); assert_eq!(project.dependency("miniquad").unwrap().version, "1.2.3"); + assert_eq!( + project + .target_dependency_features( + "miniquad", + "aarch64-linux-android", + &["mobile".into()], + false, + false, + ) + .unwrap(), + Some(vec!["runtime".into()]) + ); + assert_eq!( + project + .target_dependency_features("missing", "aarch64-linux-android", &[], false, false,) + .unwrap(), + None + ); } #[test] diff --git a/crossbundle/tools/src/toolchain/plan.rs b/crossbundle/tools/src/toolchain/plan.rs index 80559894..dabc8e82 100644 --- a/crossbundle/tools/src/toolchain/plan.rs +++ b/crossbundle/tools/src/toolchain/plan.rs @@ -116,20 +116,21 @@ pub fn plan(request: &PlanRequest, environment: &Environment) -> BuildPlan { environment, ); let mut required = Vec::new(); - if request.runtime == AndroidRuntime::Miniquad + if request.runtime.requires_gradle() && request.strategy != PlanStrategy::GradleApk && !request.library_only { + let runtime = request.runtime.as_str(); diagnostics.checks.push(DoctorCheck { id: "project.android.runtime".into(), status: CheckStatus::Fail, category: "Project".into(), - summary: "The Miniquad runtime requires the Gradle APK strategy".into(), + summary: format!("The {runtime} runtime requires the Gradle APK strategy"), required: true, found: None, expected: None, source: Some("package.metadata.android.runtime".into()), - remediation: Some("Use `--strategy gradle-apk`; native APK/AAB packaging does not compile Miniquad's Java runtime".into()), + remediation: Some(format!("Use `--strategy gradle-apk`; native APK/AAB packaging does not compile the {runtime} Java runtime")), }); } if request.operation == PlanOperation::Run && !request.library_only { @@ -359,6 +360,23 @@ mod tests { assert!(check.remediation.as_deref().unwrap().contains("gradle-apk")); } + #[test] + fn game_activity_requires_gradle_packaging() { + for strategy in [PlanStrategy::NativeApk, PlanStrategy::NativeAab] { + let mut request = request(PlanOperation::Build, strategy); + request.runtime = AndroidRuntime::GameActivity; + let plan = plan(&request, &Environment::default()); + let check = plan + .diagnostics + .checks + .iter() + .find(|check| check.id == "project.android.runtime") + .unwrap(); + assert_eq!(check.status, CheckStatus::Fail); + assert!(check.summary.contains("game-activity")); + } + } + #[test] fn library_plan_does_not_require_unused_packaging_tools() { let mut request = request(PlanOperation::Build, PlanStrategy::NativeAab); diff --git a/crossbundle/tools/src/types/android/android_runtime.rs b/crossbundle/tools/src/types/android/android_runtime.rs index b1964757..afd1e125 100644 --- a/crossbundle/tools/src/types/android/android_runtime.rs +++ b/crossbundle/tools/src/types/android/android_runtime.rs @@ -7,11 +7,28 @@ pub enum AndroidRuntime { #[default] #[serde(rename = "native-activity")] NativeActivity, + /// Android Game Development Kit's GameActivity, used by Bevy and other native applications. + #[serde(rename = "game-activity")] + GameActivity, /// Miniquad's Java Activity and JNI bridge, used by Macroquad. #[serde(rename = "miniquad")] Miniquad, } +impl AndroidRuntime { + pub const fn as_str(self) -> &'static str { + match self { + Self::NativeActivity => "native-activity", + Self::GameActivity => "game-activity", + Self::Miniquad => "miniquad", + } + } + + pub const fn requires_gradle(self) -> bool { + matches!(self, Self::GameActivity | Self::Miniquad) + } +} + #[cfg(test)] mod tests { use super::*; @@ -23,5 +40,11 @@ mod tests { serde_json::from_str::("\"miniquad\"").unwrap(), AndroidRuntime::Miniquad ); + assert_eq!( + serde_json::from_str::("\"game-activity\"").unwrap(), + AndroidRuntime::GameActivity + ); + assert!(AndroidRuntime::GameActivity.requires_gradle()); + assert!(!AndroidRuntime::NativeActivity.requires_gradle()); } } diff --git a/crossbundle/tools/src/types/android/manifest.rs b/crossbundle/tools/src/types/android/manifest.rs index 13b0c0eb..93913e3c 100644 --- a/crossbundle/tools/src/types/android/manifest.rs +++ b/crossbundle/tools/src/types/android/manifest.rs @@ -71,10 +71,15 @@ pub fn update_android_manifest_with_default( )); } if manifest.application.theme.is_none() { - manifest.application.theme = Some(Resource::new_with_package( - "Theme.DeviceDefault.NoActionBar.Fullscreen", - Some("android".to_string()), - )); + manifest.application.theme = Some(match runtime { + super::AndroidRuntime::GameActivity => { + Resource::new("Theme.AppCompat.Light.NoActionBar") + } + _ => Resource::new_with_package( + "Theme.DeviceDefault.NoActionBar.Fullscreen", + Some("android".to_string()), + ), + }); } if manifest.application.activity.is_empty() { manifest.application.activity = vec![Activity::default()]; @@ -96,6 +101,14 @@ pub fn update_android_manifest_with_default( { "com.crossbow.game.CrossbowApp".to_string() } + (AndroidStrategy::GradleApk, super::AndroidRuntime::GameActivity) + if crossbow_bridge => + { + "com.crossbow.game.CrossbowApp".to_string() + } + (_, super::AndroidRuntime::GameActivity) => { + "com.google.androidgamesdk.GameActivity".to_string() + } _ => "android.app.NativeActivity".to_string(), }; } @@ -105,19 +118,31 @@ pub fn update_android_manifest_with_default( if activity.exported.is_none() { activity.exported = VarOrBool::Bool(true).into(); } - if runtime == super::AndroidRuntime::Miniquad && activity.config_changes.is_empty() { - activity.config_changes = vec![ - ConfigChanges::Orientation, - ConfigChanges::KeyboardHidden, - ConfigChanges::ScreenSize, - ] - .into(); + if activity.config_changes.is_empty() { + activity.config_changes = match runtime { + super::AndroidRuntime::Miniquad => vec![ + ConfigChanges::Orientation, + ConfigChanges::KeyboardHidden, + ConfigChanges::ScreenSize, + ] + .into(), + super::AndroidRuntime::GameActivity => vec![ + ConfigChanges::Orientation, + ConfigChanges::KeyboardHidden, + ConfigChanges::ScreenLayout, + ConfigChanges::ScreenSize, + ] + .into(), + super::AndroidRuntime::NativeActivity => Default::default(), + }; } - if runtime == super::AndroidRuntime::NativeActivity - && !activity - .meta_data - .iter() - .any(|metadata| metadata.name.as_deref() == Some("android.app.lib_name")) + if matches!( + runtime, + super::AndroidRuntime::NativeActivity | super::AndroidRuntime::GameActivity + ) && !activity + .meta_data + .iter() + .any(|metadata| metadata.name.as_deref() == Some("android.app.lib_name")) { activity.meta_data.push(MetaData { name: Some("android.app.lib_name".to_string()), @@ -203,4 +228,32 @@ mod tests { ); assert_eq!(manifest.application.activity[0].meta_data.len(), 1); } + + #[test] + fn game_activity_uses_agdk_and_native_library_metadata() { + for (bridge, expected_activity) in [ + (false, "com.google.androidgamesdk.GameActivity"), + (true, "com.crossbow.game.CrossbowApp"), + ] { + let mut manifest = AndroidManifest::default(); + update_android_manifest_with_default( + &mut manifest, + None, + "my-game", + AndroidStrategy::GradleApk, + super::super::AndroidRuntime::GameActivity, + bridge, + ); + + assert_eq!(launcher_activity(&manifest), Some(expected_activity)); + let activity = &manifest.application.activity[0]; + assert_eq!(activity.config_changes.vec().len(), 4); + assert_eq!(activity.meta_data.len(), 1); + assert_eq!(activity.meta_data[0].value.as_deref(), Some("my_game")); + assert_eq!( + manifest.application.theme.as_ref().unwrap().to_string(), + "@style/Theme.AppCompat.Light.NoActionBar" + ); + } + } } diff --git a/docs/src/crossbow/configuration.md b/docs/src/crossbow/configuration.md index becb0354..8b59d23b 100644 --- a/docs/src/crossbow/configuration.md +++ b/docs/src/crossbow/configuration.md @@ -37,7 +37,8 @@ FEATURE_ENABLED = { env = "FEATURE_ENABLED", type = "boolean", default = false } [package.metadata.android] # Optional activity integration. The default is "native-activity"; use -# "miniquad" for Macroquad projects built with the Gradle strategy. +# "game-activity" with Bevy's android-game-activity feature, or "miniquad" +# for Macroquad projects. Both Java runtimes require the Gradle strategy. runtime = "native-activity" # Android targets to build on debug or release. debug_build_targets = ["aarch64-linux-android"] diff --git a/docs/src/crossbundle/command-build.md b/docs/src/crossbundle/command-build.md index 0814a4ac..6b25ba1f 100644 --- a/docs/src/crossbundle/command-build.md +++ b/docs/src/crossbundle/command-build.md @@ -103,9 +103,31 @@ Crossbundle forwards the selected profile and Cargo feature flags, and streams C compiler diagnostics while building. If the package does not expose a library `cdylib`, validation fails before compilation with the manifest change required to fix it. -`android-native-activity` is the recommended default because it keeps the toolchain Rust-native. -Projects that need AndroidX or other JVM integrations can instead choose Bevy's -`android-game-activity` feature and provide the corresponding Java/Gradle integration. +`android-native-activity` is the default because it also supports Crossbundle's Gradle-free native +packaging strategies. For AndroidX, complete input-method support, and a modern Android Activity, +select Bevy's GameActivity feature and Crossbow's matching runtime: + +```toml +[target.'cfg(target_os = "android")'.dependencies] +bevy = { version = "0.19", default-features = false, features = ["android-game-activity"] } + +[package.metadata.android] +runtime = "game-activity" +``` + +Build it with the default Gradle strategy: + +```sh +crossbundle run android +crossbundle build android --release --strategy gradle-apk +``` + +Crossbundle adds the compatible `androidx.games:games-activity` dependency, generates the launcher +Activity and native-library metadata, and preserves the GameActivity rendering surface when +Crossbow permissions or plugins add Android views. GameActivity intentionally rejects +`native-apk` and `native-aab`, because those strategies cannot package its AndroidX Java runtime. +Crossbundle also validates the resolved `android-activity` feature against this metadata to catch +NativeActivity/GameActivity mismatches before compilation. ### Macroquad diff --git a/examples/bevy-game-activity/Cargo.toml b/examples/bevy-game-activity/Cargo.toml new file mode 100644 index 00000000..b089d220 --- /dev/null +++ b/examples/bevy-game-activity/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bevy-game-activity" +version = "0.2.3" +authors = ["DodoRare Team "] +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +bevy = { workspace = true, default-features = false, features = ["2d"] } + +[target.'cfg(target_os = "android")'.dependencies] +bevy = { workspace = true, default-features = false, features = ["android-game-activity"] } + +[package.metadata] +app_name = "Bevy GameActivity" +assets = ["../../assets"] + +[package.metadata.android] +runtime = "game-activity" +release_build_targets = ["aarch64-linux-android"] +resources = ["../../assets/res/android"] diff --git a/examples/bevy-game-activity/src/lib.rs b/examples/bevy-game-activity/src/lib.rs new file mode 100644 index 00000000..0151e231 --- /dev/null +++ b/examples/bevy-game-activity/src/lib.rs @@ -0,0 +1,14 @@ +use bevy::prelude::*; + +#[bevy_main] +pub fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .run(); +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2d); + commands.spawn(Sprite::from_image(asset_server.load("images/icon.png"))); +} diff --git a/examples/bevy-game-activity/src/main.rs b/examples/bevy-game-activity/src/main.rs new file mode 100644 index 00000000..7ab0814a --- /dev/null +++ b/examples/bevy-game-activity/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + bevy_game_activity::main(); +} diff --git a/platform/android/java/app/build.gradle b/platform/android/java/app/build.gradle index e1a80f3a..11984a32 100644 --- a/platform/android/java/app/build.gradle +++ b/platform/android/java/app/build.gradle @@ -42,6 +42,11 @@ allprojects { } dependencies { + if (getCrossbowAndroidRuntime() == "game-activity") { + implementation libraries.appcompat + implementation libraries.gameActivity + } + if (getCrossbowBridge()) { if (rootProject.findProject(":lib")) { implementation project(":lib") diff --git a/platform/android/java/app/config.gradle b/platform/android/java/app/config.gradle index 1165924e..c2eb149c 100644 --- a/platform/android/java/app/config.gradle +++ b/platform/android/java/app/config.gradle @@ -5,7 +5,9 @@ ext.versions = [ minSdk : 23, targetSdk : 36, buildTools : "36.0.0", + appcompatVersion : "1.8.0", coreVersion : "1.13.0", + gameActivityVersion: "4.4.0", nexusPublishVersion: "1.1.0", javaVersion : 17, ndkVersion : "28.2.13676358" @@ -13,7 +15,9 @@ ext.versions = [ ext.libraries = [ androidGradlePlugin: "com.android.tools.build:gradle:$versions.androidGradlePlugin", + appcompat : "androidx.appcompat:appcompat:$versions.appcompatVersion", androidxCore : "androidx.core:core:$versions.coreVersion", + gameActivity : "androidx.games:games-activity:$versions.gameActivityVersion", crossbowLibrary : "com.crossbow.library:lib:$versions.crossbowLibrary" ] @@ -87,6 +91,11 @@ ext.getCrossbowBridge = { -> return bridge == null ? true : bridge.toBoolean() } +ext.getCrossbowAndroidRuntime = { -> + String runtime = project.findProperty("crossbow_android_runtime") + return runtime == null || runtime.isEmpty() ? "native-activity" : runtime +} + // Crossbow plugins final String VALUE_SEPARATOR_REGEX = "\\|" diff --git a/platform/android/java/lib/build.gradle b/platform/android/java/lib/build.gradle index 734e88b0..484910ad 100644 --- a/platform/android/java/lib/build.gradle +++ b/platform/android/java/lib/build.gradle @@ -6,7 +6,9 @@ apply from: "publish.gradle" dependencies { implementation libraries.androidxCore - // implementation "androidx.games:games-activity:1.1.0" + // The app supplies this only for the game-activity runtime, keeping other APKs free of AGDK. + compileOnly libraries.appcompat + compileOnly libraries.gameActivity } android { diff --git a/platform/android/java/lib/src/com/crossbow/library/Crossbow.kt b/platform/android/java/lib/src/com/crossbow/library/Crossbow.kt index b94a75ed..ee5a2276 100644 --- a/platform/android/java/lib/src/com/crossbow/library/Crossbow.kt +++ b/platform/android/java/lib/src/com/crossbow/library/Crossbow.kt @@ -1,203 +1,102 @@ -@file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") - package com.crossbow.library -import com.crossbow.library.plugin.CrossbowPluginRegistry - +import android.app.Activity import android.content.Intent -import android.content.Context -import android.util.Log -import android.os.Bundle import android.content.pm.PackageManager -import android.app.Activity -import android.app.Fragment -import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.view.LayoutInflater import android.widget.FrameLayout -import androidx.annotation.CallSuper import androidx.annotation.Keep +import com.crossbow.library.plugin.CrossbowPluginRegistry -class Crossbow : Fragment() { - private var crossbowHost: CrossbowHost? = null - public var pluginRegistry: CrossbowPluginRegistry? = null - - private var containerLayout: ViewGroup? = null - private var mCurrentIntent: Intent? = null - - fun onNewIntent(intent: Intent) { - mCurrentIntent = intent; - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pluginRegistry = CrossbowPluginRegistry.initializePluginRegistry(this) - - Log.v(TAG, "Initializing CrossbowLib Instance") - CrossbowLib.initialize(activity!!, this, activity!!.assets) - } - - override fun onAttach(context: Context) { - super.onAttach(context) - if (parentFragment is CrossbowHost) { - crossbowHost = parentFragment as CrossbowHost? - } else if (activity is CrossbowHost) { - crossbowHost = activity as CrossbowHost? - } - } - - override fun onDetach() { - super.onDetach() - crossbowHost = null - } - - @CallSuper - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - for (plugin in pluginRegistry!!.allPlugins) { - plugin.onMainActivityResult(requestCode, resultCode, data) - } - } - - /** - * Invoked on the render thread when the Crossbow setup is complete. - */ - @CallSuper - protected fun onCrossbowSetupCompleted() { - for (plugin in pluginRegistry!!.allPlugins) { - plugin.onCrossbowSetupCompleted() - } - if (crossbowHost != null) { - crossbowHost?.onCrossbowSetupCompleted() - } +/** + * Owns Crossbow's Android plugin lifecycle and overlay view. + * Construct it only after the host has loaded the application's native library. + */ +class Crossbow(val activity: Activity) { + val pluginRegistry: CrossbowPluginRegistry = + CrossbowPluginRegistry.initializePluginRegistry(this) + + val view = FrameLayout(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) } - /** - * Invoked on the render thread when the Crossbow main loop has started. - */ - @CallSuper - protected fun onCrossbowMainLoopStarted() { - for (plugin in pluginRegistry!!.allPlugins) { - plugin.onCrossbowMainLoopStarted() - } - if (crossbowHost != null) { - crossbowHost?.onCrossbowMainLoopStarted() - } + init { + CrossbowLib.initialize(this) } - /** - * Used by the native code to complete initialization of plugins and renderer. - */ + /** Called by the native layer once it is ready to register Android plugins. */ @Keep private fun onRenderInit() { - Log.v(TAG, "Calling Crossbow onRenderInit") - - containerLayout = FrameLayout(activity) - containerLayout?.setLayoutParams( - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - - for (plugin in pluginRegistry!!.allPlugins) { + for (plugin in plugins()) { plugin.onRegisterPluginWithCrossbowNative() } - - // Include the returned non-null views in the Crossbow view hierarchy. - for (plugin in pluginRegistry!!.allPlugins) { - val pluginView: View? = plugin.onMainCreate(activity) - if (pluginView !== null) { - if (plugin.shouldBeOnTop()) { - containerLayout?.addView(pluginView) - } else { - containerLayout?.addView(pluginView, 0) - } + for (plugin in plugins()) { + plugin.onMainCreate(activity)?.let { pluginView -> + view.addView(pluginView, if (plugin.shouldBeOnTop()) view.childCount else 0) } } - - Log.v(TAG, "Crossbow onRenderInit finished") } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = checkNotNull(containerLayout) { "Crossbow native initialization did not create its view" } + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + for (plugin in plugins()) { + plugin.onMainActivityResult(requestCode, resultCode, data) + } + } - override fun onDestroy() { - for (plugin in pluginRegistry!!.allPlugins) { - plugin.onMainDestroy() + fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + for (plugin in plugins()) { + plugin.onMainRequestPermissionsResult(requestCode, permissions, grantResults) + } + permissions.forEachIndexed { index, permission -> + CrossbowLib.requestPermissionResult( + permission, + grantResults.getOrNull(index) == PackageManager.PERMISSION_GRANTED + ) } - CrossbowPluginRegistry.clearPluginRegistry(pluginRegistry) - pluginRegistry = null - CrossbowLib.onDestroy() - super.onDestroy() } - override fun onPause() { - super.onPause() - CrossbowLib.focusOut() - for (plugin in pluginRegistry!!.allPlugins) { + fun onPause() { + for (plugin in plugins()) { plugin.onMainPause() } } - override fun onResume() { - super.onResume() - CrossbowLib.focusIn() - for (plugin in pluginRegistry!!.allPlugins) { + fun onResume() { + for (plugin in plugins()) { plugin.onMainResume() } } - fun onBackPressed() { - var shouldQuit = true - for (plugin in pluginRegistry!!.allPlugins) { - if (plugin.onMainBackPressed()) { - shouldQuit = false - } + fun onBackPressed(): Boolean = + plugins().fold(false) { handled, plugin -> + plugin.onMainBackPressed() || handled } - if (shouldQuit) { - CrossbowLib.onBackPressed() - } - } - fun runOnUiThread(action: Runnable) { - if (activity != null) { - activity!!.runOnUiThread(action) - } - } - - @CallSuper - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - for (plugin in pluginRegistry!!.allPlugins) { - plugin.onMainRequestPermissionsResult(requestCode, permissions, grantResults) - } - for (i in permissions.indices) { - CrossbowLib.requestPermissionResult( - permissions[i], - grantResults[i] == PackageManager.PERMISSION_GRANTED - ) + fun onDestroy() { + for (plugin in plugins()) { + plugin.onMainDestroy() } + CrossbowPluginRegistry.clearPluginRegistry(pluginRegistry) } + fun runOnUiThread(action: Runnable) = activity.runOnUiThread(action) + val grantedPermissions: Array get() = PermissionsUtil.getGrantedPermissions(activity) @Keep - fun requestPermission(permission: String): Boolean { - return PermissionsUtil.requestPermission(permission, activity) - } + fun requestPermission(permission: String): Boolean = + PermissionsUtil.requestPermission(permission, activity) @Keep - fun requestPermissions(): Boolean { - return PermissionsUtil.requestManifestPermissions(activity) - } + fun requestPermissions(): Boolean = PermissionsUtil.requestManifestPermissions(activity) + + private fun plugins() = pluginRegistry.allPlugins } diff --git a/platform/android/java/lib/src/com/crossbow/library/CrossbowGameActivity.kt b/platform/android/java/lib/src/com/crossbow/library/CrossbowGameActivity.kt index 364fd887..b398f231 100644 --- a/platform/android/java/lib/src/com/crossbow/library/CrossbowGameActivity.kt +++ b/platform/android/java/lib/src/com/crossbow/library/CrossbowGameActivity.kt @@ -1,35 +1,51 @@ package com.crossbow.library -// import android.app.Activity -// import com.google.androidgamesdk.GameActivity - -// open class CrossbowGameActivity : GameActivity() { -// companion object { -// init { -// // This is necessary when any of the following happens: -// // - crossbow_android library is not configured to the following line in the manifest: -// // -// // - GameActivity derived class calls to the native code before calling -// // the super.onCreate() function. -// System.loadLibrary("crossbow_android") -// } -// } - -// override fun onCreate(savedInstanceState: Bundle?) { -// super.onCreate(savedInstanceState) -// setContentView(R.layout.crossbow_app_layout) - -// val currentFragment: Fragment = -// fragmentManager.findFragmentById(R.id.crossbow_fragment_container) -// if (currentFragment is Crossbow) { -// Log.v(TAG, "Reusing existing Crossbow fragment instance.") -// crossbowFragment = currentFragment as Crossbow -// } else { -// Log.v(TAG, "Creating new Crossbow fragment instance") -// crossbowFragment = Crossbow() -// getFragmentManager().beginTransaction() -// .replace(R.id.crossbow_fragment_container, crossbowFragment as Fragment) -// .setPrimaryNavigationFragment(crossbowFragment as Fragment).commitNowAllowingStateLoss() -// } -// } -// } +import android.content.Intent +import android.os.Bundle +import android.view.ViewGroup +import androidx.activity.addCallback +import com.google.androidgamesdk.GameActivity + +/** GameActivity host that overlays Crossbow plugin UI without replacing the game SurfaceView. */ +open class CrossbowGameActivity : GameActivity() { + protected lateinit var crossbow: Crossbow + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + crossbow = Crossbow(this) + findViewById(contentViewId).addView(crossbow.view) + onBackPressedDispatcher.addCallback(this) { + if (!crossbow.onBackPressed()) finish() + } + } + + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + crossbow.onActivityResult(requestCode, resultCode, data) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + crossbow.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onPause() { + crossbow.onPause() + super.onPause() + } + + override fun onResume() { + super.onResume() + if (::crossbow.isInitialized) crossbow.onResume() + } + + override fun onDestroy() { + if (::crossbow.isInitialized) crossbow.onDestroy() + super.onDestroy() + } +} diff --git a/platform/android/java/lib/src/com/crossbow/library/CrossbowHost.kt b/platform/android/java/lib/src/com/crossbow/library/CrossbowHost.kt deleted file mode 100644 index cbc50dea..00000000 --- a/platform/android/java/lib/src/com/crossbow/library/CrossbowHost.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.crossbow.library - -import com.crossbow.library.Crossbow - -/** - * Denotate a component (e.g: Activity, Fragment) that hosts the [Crossbow] fragment. - */ -interface CrossbowHost { - /** - * Provides a set of command line parameters to setup the engine. - */ - val commandLine: List? - get() = emptyList() - - /** - * Invoked on the render thread when the Crossbow setup is complete. - */ - fun onCrossbowSetupCompleted() {} - - /** - * Invoked on the render thread when the Crossbow main loop has started. - */ - fun onCrossbowMainLoopStarted() {} - - /** - * Invoked on the UI thread as the last step of the Crossbow instance clean up phase. - */ - fun onCrossbowForceQuit(instance: Crossbow?) {} - - /** - * Invoked on the UI thread when the Crossbow instance wants to be restarted. It's up to the host - * to perform the appropriate action(s). - */ - fun onCrossbowRestartRequested(instance: Crossbow?) {} - - /** - * Invoked on the UI thread when a new Crossbow instance is requested. It's up to the host to - * perform the appropriate action(s). - * - * @param args Arguments used to initialize the new instance. - */ - fun onNewCrossbowInstanceRequested(args: Array?) {} -} diff --git a/platform/android/java/lib/src/com/crossbow/library/CrossbowLib.kt b/platform/android/java/lib/src/com/crossbow/library/CrossbowLib.kt index b9425eb5..525d7177 100644 --- a/platform/android/java/lib/src/com/crossbow/library/CrossbowLib.kt +++ b/platform/android/java/lib/src/com/crossbow/library/CrossbowLib.kt @@ -3,7 +3,7 @@ package com.crossbow.library import android.app.Activity object CrossbowLib { - /** Initializes ndk-context for Java-Activity runtimes such as Miniquad. */ + /** Initializes ndk-context for the Miniquad Java runtime. */ @JvmStatic external fun initializeAndroidContext(activity: Activity) @@ -15,38 +15,7 @@ object CrossbowLib { * Invoked on the main thread to initialize Crossbow native layer. */ @JvmStatic - external fun initialize( - activity: Activity, - instance: Crossbow, - asset_manager: Any - ) - - /** - * Invoked on the main thread to clean up Crossbow native layer. - * @see androidx.fragment.app.Fragment.onDestroy - */ - @JvmStatic - external fun onDestroy() - - /** - * Forward [Activity.onBackPressed] event from the main thread to the GL thread. - */ - @JvmStatic - external fun onBackPressed() - - /** - * Invoked when the Android app resumes. - * @see androidx.fragment.app.Fragment#onResume() - */ - @JvmStatic - external fun focusIn() - - /** - * Invoked when the Android app pauses. - * @see androidx.fragment.app.Fragment#onPause() - */ - @JvmStatic - external fun focusOut() + external fun initialize(instance: Crossbow) /** * Forward the results from a permission request. diff --git a/platform/android/java/lib/src/com/crossbow/library/CrossbowNativeActivity.kt b/platform/android/java/lib/src/com/crossbow/library/CrossbowNativeActivity.kt index d1cabe44..4dff3159 100644 --- a/platform/android/java/lib/src/com/crossbow/library/CrossbowNativeActivity.kt +++ b/platform/android/java/lib/src/com/crossbow/library/CrossbowNativeActivity.kt @@ -1,74 +1,53 @@ -@file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") - package com.crossbow.library -import android.util.Log -import android.os.Bundle -import android.app.Fragment -import android.app.Activity -import android.content.Intent import android.app.NativeActivity -import android.content.pm.PackageManager +import android.content.Intent +import android.os.Bundle import android.widget.FrameLayout -import android.widget.FrameLayout.LayoutParams -import androidx.annotation.CallSuper -open class CrossbowNativeActivity : NativeActivity(), CrossbowHost { - companion object { - const val CONTENT_VIEW_ID = 10101010 - } - protected var crossbowFragment: Crossbow? = null +open class CrossbowNativeActivity : NativeActivity() { + protected lateinit var crossbow: Crossbow - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val frame = FrameLayout(this) - frame.setId(CONTENT_VIEW_ID) - setContentView(frame, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - - if (savedInstanceState === null) { - crossbowFragment = Crossbow() - fragmentManager.beginTransaction().add(CONTENT_VIEW_ID, crossbowFragment).commit() - } - } - - override fun onDestroy() { - Log.v(TAG, "Destroying Crossbow app...") - super.onDestroy() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val content = FrameLayout(this) + setContentView(content) + crossbow = Crossbow(this) + content.addView(crossbow.view) } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - if (crossbowFragment !== null) { - crossbowFragment?.onNewIntent(intent) - } - } - - @CallSuper + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - if (crossbowFragment !== null) { - crossbowFragment?.onActivityResult(requestCode, resultCode, data) - } + crossbow.onActivityResult(requestCode, resultCode, data) } - @CallSuper override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (crossbowFragment !== null) { - crossbowFragment?.onRequestPermissionsResult(requestCode, permissions, grantResults) - } + crossbow.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onPause() { + crossbow.onPause() + super.onPause() + } + + override fun onResume() { + super.onResume() + if (::crossbow.isInitialized) crossbow.onResume() + } + + override fun onDestroy() { + if (::crossbow.isInitialized) crossbow.onDestroy() + super.onDestroy() } + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onBackPressed() { - if (crossbowFragment !== null) { - crossbowFragment?.onBackPressed() - } else { - super.onBackPressed() - } + if (!crossbow.onBackPressed()) super.onBackPressed() } } diff --git a/platform/android/java/lib/src/com/crossbow/library/PermissionsUtil.kt b/platform/android/java/lib/src/com/crossbow/library/PermissionsUtil.kt index 358191e0..b2b50891 100644 --- a/platform/android/java/lib/src/com/crossbow/library/PermissionsUtil.kt +++ b/platform/android/java/lib/src/com/crossbow/library/PermissionsUtil.kt @@ -6,13 +6,13 @@ import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PermissionInfo -import android.net.Uri import android.os.Build import android.os.Environment import android.provider.Settings import android.util.Log -import kotlin.collections.List import androidx.core.content.ContextCompat +import androidx.core.content.pm.PermissionInfoCompat +import androidx.core.net.toUri /** * This class includes utility functions for Android permissions related operations. @@ -23,7 +23,6 @@ object PermissionsUtil { const val REQUEST_CAMERA_PERMISSION = 2 const val REQUEST_VIBRATE_PERMISSION = 3 const val REQUEST_ALL_PERMISSION_REQ_CODE = 1001 - const val REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE = 2002 /** * Request a dangerous permission. name must be specified in [this](https://github.com/aosp-mirror/platform_frameworks_base/blob/master/core/res/AndroidManifest.xml) @@ -32,10 +31,6 @@ object PermissionsUtil { * @return true/false. "true" if permission was granted otherwise returns "false". */ fun requestPermission(name: String, activity: Activity): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // Not necessary, asked on install already - return true - } if (name == "RECORD_AUDIO" && ContextCompat.checkSelfPermission( activity, Manifest.permission.RECORD_AUDIO @@ -78,17 +73,13 @@ object PermissionsUtil { * @return true/false. "true" if all permissions were granted otherwise returns "false". */ fun requestManifestPermissions(activity: Activity): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return true - } - val manifestPermissions: Array - manifestPermissions = try { + val manifestPermissions = try { getManifestPermissions(activity) } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() + Log.w(TAG, "Unable to read manifest permissions", e) return false } - if (manifestPermissions.size == 0) return true + if (manifestPermissions.isEmpty()) return true val requestedPermissions: MutableList = ArrayList() for (manifestPermission in manifestPermissions) { try { @@ -97,34 +88,16 @@ object PermissionsUtil { try { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - intent.setData( - Uri.parse( - String.format( - "package:%s", - activity.getPackageName() - ) - ) - ) - activity.startActivityForResult( - intent, - REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE - ) + intent.data = "package:${activity.packageName}".toUri() + activity.startActivity(intent) } catch (ignored: Exception) { val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - activity.startActivityForResult( - intent, - REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE - ) + activity.startActivity(intent) } } } else { val permissionInfo: PermissionInfo = getPermissionInfo(activity, manifestPermission) - val protectionLevel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - permissionInfo.getProtection() - } else { - @Suppress("DEPRECATION") - permissionInfo.protectionLevel - } + val protectionLevel = PermissionInfoCompat.getProtection(permissionInfo) if (protectionLevel == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission( activity, manifestPermission @@ -155,14 +128,13 @@ object PermissionsUtil { * @return granted permissions list */ fun getGrantedPermissions(activity: Activity): Array { - val manifestPermissions: Array - manifestPermissions = try { + val manifestPermissions = try { getManifestPermissions(activity) } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() - return arrayOf() + Log.w(TAG, "Unable to read manifest permissions", e) + return emptyArray() } - if (manifestPermissions.size == 0) return manifestPermissions + if (manifestPermissions.isEmpty()) return manifestPermissions val grantedPermissions: MutableList = ArrayList() for (manifestPermission in manifestPermissions) { try { @@ -172,12 +144,7 @@ object PermissionsUtil { } } else { val permissionInfo: PermissionInfo = getPermissionInfo(activity, manifestPermission) - val protectionLevel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - permissionInfo.getProtection() - } else { - @Suppress("DEPRECATION") - permissionInfo.protectionLevel - } + val protectionLevel = PermissionInfoCompat.getProtection(permissionInfo) if (protectionLevel == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission( activity, manifestPermission @@ -218,10 +185,10 @@ object PermissionsUtil { */ @Throws(PackageManager.NameNotFoundException::class) private fun getManifestPermissions(activity: Activity): Array { - val packageManager: PackageManager = activity.getPackageManager() + val packageManager = activity.packageManager val packageInfo: PackageInfo = - packageManager.getPackageInfo(activity.getPackageName(), PackageManager.GET_PERMISSIONS) - return packageInfo.requestedPermissions?.map { it }?.toTypedArray() ?: emptyArray() + packageManager.getPackageInfo(activity.packageName, PackageManager.GET_PERMISSIONS) + return packageInfo.requestedPermissions ?: emptyArray() } /** @@ -233,7 +200,7 @@ object PermissionsUtil { */ @Throws(PackageManager.NameNotFoundException::class) private fun getPermissionInfo(activity: Activity, permission: String): PermissionInfo { - val packageManager: PackageManager = activity.getPackageManager() + val packageManager = activity.packageManager return packageManager.getPermissionInfo(permission, 0) } } diff --git a/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPlugin.kt b/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPlugin.kt index 0642d098..d86e456a 100644 --- a/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPlugin.kt +++ b/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPlugin.kt @@ -52,8 +52,7 @@ abstract class CrossbowPlugin( /** * Provides access to the underlying [Activity]. */ - protected val activity: Activity? - @Suppress("DEPRECATION") + protected val activity: Activity get() = crossbow.activity /** @@ -113,23 +112,11 @@ abstract class CrossbowPlugin( */ open fun onMainDestroy() {} - /** - * @see Activity.onBackPressed - */ + /** Invoked when the user requests back navigation. */ open fun onMainBackPressed(): Boolean { return false } - /** - * Invoked on the render thread when the Crossbow setup is complete. - */ - open fun onCrossbowSetupCompleted() {} - - /** - * Invoked on the render thread when the Crossbow main loop has started. - */ - open fun onCrossbowMainLoopStarted() {} - /** * Invoked once per frame on the GL thread after the frame is drawn. */ diff --git a/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPluginRegistry.kt b/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPluginRegistry.kt index 8b49f5e0..22abd0ad 100644 --- a/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPluginRegistry.kt +++ b/platform/android/java/lib/src/com/crossbow/library/plugin/CrossbowPluginRegistry.kt @@ -48,7 +48,6 @@ class CrossbowPluginRegistry private constructor(crossbow: Crossbow) { private fun loadPlugins(crossbow: Crossbow) { try { - @Suppress("DEPRECATION") val activity = crossbow.activity val appInfo = activity .packageManager @@ -65,7 +64,7 @@ class CrossbowPluginRegistry private constructor(crossbow: Crossbow) { // Parse the meta-data looking for entry with the Crossbow plugin name prefix. if (metaDataName.startsWith(CROSSBOW_PLUGIN_V1_NAME_PREFIX)) { val pluginName = - metaDataName.substring(crossbowPluginV1NamePrefixLength).trim { it <= ' ' } + metaDataName.substring(crossbowPluginV1NamePrefixLength).trim() Log.i(TAG, "Initializing Crossbow plugin $pluginName") // Retrieve the plugin class full name. @@ -129,14 +128,10 @@ class CrossbowPluginRegistry private constructor(crossbow: Crossbow) { * @return A singleton instance of [CrossbowPluginRegistry]. This ensures that only one instance * of each Crossbow Android plugins is available at runtime. */ - fun initializePluginRegistry(crossbow: Crossbow): CrossbowPluginRegistry? { - if (instance == null) { - instance = CrossbowPluginRegistry(crossbow) - } - return instance - } + fun initializePluginRegistry(crossbow: Crossbow): CrossbowPluginRegistry = + instance ?: CrossbowPluginRegistry(crossbow).also { instance = it } - fun clearPluginRegistry(registry: CrossbowPluginRegistry?) { + fun clearPluginRegistry(registry: CrossbowPluginRegistry) { if (instance === registry) { instance = null } @@ -149,10 +144,7 @@ class CrossbowPluginRegistry private constructor(crossbow: Crossbow) { * @throws IllegalStateException if [CrossbowPluginRegistry.initializePluginRegistry] has not been called prior to calling this method. */ @get:Throws(IllegalStateException::class) - val pluginRegistry: CrossbowPluginRegistry? - get() { - checkNotNull(instance) { "Plugin registry hasn't been initialized." } - return instance - } + val pluginRegistry: CrossbowPluginRegistry + get() = checkNotNull(instance) { "Plugin registry hasn't been initialized." } } } diff --git a/platform/android/src/crossbow.rs b/platform/android/src/crossbow.rs index af9cbb5c..8e78bd9c 100644 --- a/platform/android/src/crossbow.rs +++ b/platform/android/src/crossbow.rs @@ -26,14 +26,7 @@ impl CrossbowInstance { T::from_java_vm(self.vm.clone()) } - pub(crate) fn crossbow_on_initialize( - env: &mut Env, - activity: &JObject, - crossbow_instance: &JObject, - _asset_manager: &JObject, - ) -> Result<()> { - println!("CrossbowLib_initialize: {:?}", activity); - + pub(crate) fn crossbow_on_initialize(env: &mut Env, crossbow_instance: &JObject) -> Result<()> { env.call_method( crossbow_instance, jni_str!("onRenderInit"), @@ -44,26 +37,6 @@ impl CrossbowInstance { Ok(()) } - pub(crate) fn crossbow_on_back_pressed(_env: &mut Env) -> Result<()> { - println!("CrossbowLib_onBackPressed"); - Ok(()) - } - - pub(crate) fn crossbow_on_destroy(_env: &mut Env) -> Result<()> { - println!("CrossbowLib_onDestroy"); - Ok(()) - } - - pub(crate) fn crossbow_on_focus_in(_env: &mut Env) -> Result<()> { - println!("CrossbowLib_focus_in"); - Ok(()) - } - - pub(crate) fn crossbow_on_focus_out(_env: &mut Env) -> Result<()> { - println!("CrossbowLib_focus_out"); - Ok(()) - } - pub(crate) fn on_request_permission_result( env: &mut Env, permission: &JString, diff --git a/platform/android/src/externs/crossbow_lib.rs b/platform/android/src/externs/crossbow_lib.rs index a40006d4..c5bee030 100644 --- a/platform/android/src/externs/crossbow_lib.rs +++ b/platform/android/src/externs/crossbow_lib.rs @@ -53,53 +53,9 @@ pub extern "system" fn Java_com_crossbow_library_CrossbowLib_releaseAndroidConte pub extern "system" fn Java_com_crossbow_library_CrossbowLib_initialize<'local>( mut env: EnvUnowned<'local>, _class: JClass<'local>, - activity: JObject<'local>, crossbow_instance: JObject<'local>, - asset_manager: JObject<'local>, -) { - env.with_env(|env| { - CrossbowInstance::crossbow_on_initialize(env, &activity, &crossbow_instance, &asset_manager) - }) - .resolve::(); -} - -#[unsafe(no_mangle)] -#[allow(non_snake_case)] -pub extern "system" fn Java_com_crossbow_library_CrossbowLib_onBackPressed<'local>( - mut env: EnvUnowned<'local>, - _class: JClass<'local>, -) { - env.with_env(CrossbowInstance::crossbow_on_back_pressed) - .resolve::(); -} - -#[unsafe(no_mangle)] -#[allow(non_snake_case)] -pub extern "system" fn Java_com_crossbow_library_CrossbowLib_onDestroy<'local>( - mut env: EnvUnowned<'local>, - _class: JClass<'local>, -) { - env.with_env(CrossbowInstance::crossbow_on_destroy) - .resolve::(); -} - -#[unsafe(no_mangle)] -#[allow(non_snake_case)] -pub extern "system" fn Java_com_crossbow_library_CrossbowLib_focusIn<'local>( - mut env: EnvUnowned<'local>, - _class: JClass<'local>, -) { - env.with_env(CrossbowInstance::crossbow_on_focus_in) - .resolve::(); -} - -#[unsafe(no_mangle)] -#[allow(non_snake_case)] -pub extern "system" fn Java_com_crossbow_library_CrossbowLib_focusOut<'local>( - mut env: EnvUnowned<'local>, - _class: JClass<'local>, ) { - env.with_env(CrossbowInstance::crossbow_on_focus_out) + env.with_env(|env| CrossbowInstance::crossbow_on_initialize(env, &crossbow_instance)) .resolve::(); } diff --git a/plugins/play-billing/android/src/com/crossbow/play_billing/CrossbowPlayBilling.kt b/plugins/play-billing/android/src/com/crossbow/play_billing/CrossbowPlayBilling.kt index d1b21772..7d0d3b4b 100644 --- a/plugins/play-billing/android/src/com/crossbow/play_billing/CrossbowPlayBilling.kt +++ b/plugins/play-billing/android/src/com/crossbow/play_billing/CrossbowPlayBilling.kt @@ -21,7 +21,7 @@ import com.crossbow.library.plugin.SignalInfo class CrossbowPlayBilling(crossbow: Crossbow) : CrossbowPlugin(crossbow), PurchasesUpdatedListener, BillingClientStateListener { - private val billingClient: BillingClient = BillingClient.newBuilder(activity!!) + private val billingClient: BillingClient = BillingClient.newBuilder(activity) .enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() @@ -170,7 +170,7 @@ class CrossbowPlayBilling(crossbow: Crossbow) : CrossbowPlugin(crossbow), } } .build() - val result = billingClient.launchBillingFlow(activity!!, flowParams) + val result = billingClient.launchBillingFlow(activity, flowParams) return if (result.responseCode == BillingClient.BillingResponseCode.OK) { Dictionary().apply { this["status"] = 0 } } else { diff --git a/plugins/play-core/android/src/com/crossbow/play_core/CrossbowPlayCore.kt b/plugins/play-core/android/src/com/crossbow/play_core/CrossbowPlayCore.kt index ba97a19b..b4a2bf35 100644 --- a/plugins/play-core/android/src/com/crossbow/play_core/CrossbowPlayCore.kt +++ b/plugins/play-core/android/src/com/crossbow/play_core/CrossbowPlayCore.kt @@ -18,7 +18,7 @@ class CrossbowPlayCore(crossbow: Crossbow) : CrossbowPlugin(crossbow) { private val REQUEST_CODE = 100 init { - appUpdate = AppUpdateManagerFactory.create(crossbow.activity!!) + appUpdate = AppUpdateManagerFactory.create(crossbow.activity) } override val pluginName: String diff --git a/plugins/play-games-services/android/src/com/crossbow/play_games_services/CrossbowPlayGamesServices.kt b/plugins/play-games-services/android/src/com/crossbow/play_games_services/CrossbowPlayGamesServices.kt index cc17fbea..ff406e82 100644 --- a/plugins/play-games-services/android/src/com/crossbow/play_games_services/CrossbowPlayGamesServices.kt +++ b/plugins/play-games-services/android/src/com/crossbow/play_games_services/CrossbowPlayGamesServices.kt @@ -1,6 +1,7 @@ package com.crossbow.play_games_services import android.content.Intent +import androidx.core.content.IntentCompat import com.google.android.gms.games.SnapshotsClient import com.google.android.gms.games.snapshot.SnapshotMetadata import com.crossbow.play_games_services.accountinfo.PlayerInfoController @@ -115,7 +116,11 @@ class CrossbowPlayGamesServices(crossbow: Crossbow) : CrossbowPlugin(crossbow), if (requestCode == SavedGamesController.RC_SAVED_GAMES) { if (data != null) { if (data.hasExtra(SnapshotsClient.EXTRA_SNAPSHOT_METADATA)) { - data.getParcelableExtra(SnapshotsClient.EXTRA_SNAPSHOT_METADATA)?.let { + IntentCompat.getParcelableExtra( + data, + SnapshotsClient.EXTRA_SNAPSHOT_METADATA, + SnapshotMetadata::class.java + )?.let { savedGamesController.loadSnapshot(it.uniqueName) } } else if (data.hasExtra(SnapshotsClient.EXTRA_SNAPSHOT_NEW)) { @@ -128,13 +133,13 @@ class CrossbowPlayGamesServices(crossbow: Crossbow) : CrossbowPlugin(crossbow), private fun initialize(enableSaveGamesFunctionality: Boolean, enablePopups: Boolean, saveGameName: String) { this.saveGameName = saveGameName - signInController = SignInController(crossbow.activity!!, this) - achievementsController = AchievementsController(crossbow.activity!!, this) - leaderboardsController = LeaderboardsController(crossbow.activity!!, this) - eventsController = EventsController(crossbow.activity!!, this) - playerStatsController = PlayerStatsController(crossbow.activity!!, this) - playerInfoController = PlayerInfoController(crossbow.activity!!, this) - savedGamesController = SavedGamesController(crossbow.activity!!, this) + signInController = SignInController(crossbow.activity, this) + achievementsController = AchievementsController(crossbow.activity, this) + leaderboardsController = LeaderboardsController(crossbow.activity, this) + eventsController = EventsController(crossbow.activity, this) + playerStatsController = PlayerStatsController(crossbow.activity, this) + playerInfoController = PlayerInfoController(crossbow.activity, this) + savedGamesController = SavedGamesController(crossbow.activity, this) // PGS v2 handles saved-games authorization and popup placement automatically. // Keep both flags in the stable Crossbow API while authentication is refreshed. diff --git a/plugins/play-games-services/android/src/com/crossbow/play_games_services/events/EventsController.kt b/plugins/play-games-services/android/src/com/crossbow/play_games_services/events/EventsController.kt index df216cb4..45dfbe2a 100644 --- a/plugins/play-games-services/android/src/com/crossbow/play_games_services/events/EventsController.kt +++ b/plugins/play-games-services/android/src/com/crossbow/play_games_services/events/EventsController.kt @@ -46,6 +46,6 @@ class EventsController( put("name", event.name) put("value", event.value) put("description", event.description) - put("imgUrl", event.iconImageUrl) + put("imgUrl", event.iconImageUri) } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index db91bc03..0491c707 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -21,6 +21,7 @@ const GRADLE_VERSION_FIELDS: &[(&str, &str, bool)] = &[ ("buildTools", "android_build_tools", true), ("appcompatVersion", "androidx_appcompat", true), ("coreVersion", "androidx_core", true), + ("gameActivityVersion", "androidx_games_activity", true), ("javaVersion", "java_bytecode", false), ("ndkVersion", "android_ndk", true), ]; @@ -186,7 +187,12 @@ fn check_gradle_config( } } if path.ends_with("platform/android/java/app/config.gradle") { - for required in ["coreVersion", "ndkVersion"] { + for required in [ + "appcompatVersion", + "coreVersion", + "gameActivityVersion", + "ndkVersion", + ] { if !found.contains(required) { failures.push(format!("{}: missing {required}", path.display())); }