Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/latest-dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crossbundle/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 9 additions & 1 deletion crossbundle/cli/src/commands/build/android.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions crossbundle/cli/src/commands/build/build_context.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -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<String> {
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"));
}
}
2 changes: 2 additions & 0 deletions crossbundle/tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
100 changes: 75 additions & 25 deletions crossbundle/tools/src/commands/android/gradle/gen_gradle_project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -60,6 +63,7 @@ pub fn gen_gradle_project(
sdk_versions,
plugins,
crossbow_bridge,
runtime,
),
)?;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ViewGroup>(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(
Expand All @@ -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()
}}
}}
"#
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"));
}

Expand Down
Loading