diff --git a/.github/workflows/android-test-apk.yml b/.github/workflows/android-test-apk.yml new file mode 100644 index 0000000..6c7064b --- /dev/null +++ b/.github/workflows/android-test-apk.yml @@ -0,0 +1,80 @@ +name: Android Test APK + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/android-test-apk.yml" + - "Cargo.lock" + - "Cargo.toml" + - "build.rs" + - "src/**" + - "tools/**" + - "ui/**" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + ANDROID_BUILD_TOOLS_VERSION: "35.0.0" + ANDROID_NDK_VERSION: "27.0.12077973" + +jobs: + build: + name: Build manual-test APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Linux GUI dependencies + run: | + sudo apt-get update + sudo apt-get install -y libfontconfig1-dev libxkbcommon-dev + + - uses: android-actions/setup-android@v3 + + - name: Install Android SDK packages + run: | + sdkmanager \ + "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \ + "platforms;android-30" \ + "platforms;android-35" \ + "ndk;${ANDROID_NDK_VERSION}" + echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV" + + - name: Install Rust Android target and cargo-apk + run: | + rustup target add aarch64-linux-android + cargo install cargo-apk --locked + + - name: Build guarded runner + run: | + mkdir -p target + rustc tools/guarded-run.rs -O -o target/guarded-run + + - name: Build APK + run: | + ./target/guarded-run --timeout-secs 1800 --heartbeat-secs 30 -- \ + cargo apk build --features android-gui --target aarch64-linux-android --lib + + - name: Verify manifest permissions + run: | + AAPT="${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/aapt" + APK="target/debug/apk/vimit.apk" + "$AAPT" dump permissions "$APK" | tee target/debug/apk/permissions.txt + grep -q "android.permission.INTERNET" target/debug/apk/permissions.txt + grep -q "android.permission.VIBRATE" target/debug/apk/permissions.txt + grep -q "android.permission.POST_NOTIFICATIONS" target/debug/apk/permissions.txt + sha256sum "$APK" > target/debug/apk/vimit.apk.sha256 + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: vimit-android-test-apk + path: | + target/debug/apk/vimit.apk + target/debug/apk/vimit.apk.sha256 + target/debug/apk/permissions.txt + if-no-files-found: error diff --git a/docs/android.md b/docs/android.md index 0ee7d37..12ba687 100644 --- a/docs/android.md +++ b/docs/android.md @@ -17,14 +17,13 @@ Slint supports Android through the `backend-android-activity-06` backend. The project now has a separate Cargo feature: ```bash -cargo build --features android-gui --target aarch64-linux-android +cargo apk build --features android-gui --target aarch64-linux-android --lib ``` -The local Windows environment has Rust Android targets installed, but does not -currently have the Android SDK/NDK compiler tools, `cargo-apk`, `cargo-ndk`, -`adb`, or Gradle. A target check reaches native dependency compilation and then -fails because `aarch64-linux-android-clang` is missing, so APK validation must -be done after installing Android SDK/NDK tooling. +The local Windows environment has Android SDK/NDK tooling and `cargo-apk` +available when `ANDROID_HOME` / `ANDROID_NDK_HOME` are configured. If the build +fails before Rust compilation with missing `aarch64-linux-android-clang`, +install the Android NDK and point `ANDROID_NDK_HOME` to it. ## Proposed architecture @@ -54,6 +53,43 @@ to the user and compliant with Android background execution limits. 4. Add notification/widget mode. 5. Only then test optional native overlay permission flow. +## Manual test APK + +Use the GitHub Actions workflow for a safe manual-test APK without creating a +production release: + +1. Open GitHub Actions. +2. Select `Android Test APK`. +3. Click `Run workflow` on the branch you want to test. +4. Download the `vimit-android-test-apk` artifact. +5. Install `vimit.apk` on a device or emulator. + +The artifact also contains `permissions.txt`, produced from the APK manifest. +It must include: + +- `android.permission.INTERNET` +- `android.permission.VIBRATE` +- `android.permission.POST_NOTIFICATIONS` + +Local build and manifest verification: + +```bash +rustup target add aarch64-linux-android +cargo install cargo-apk --locked +rustc tools/guarded-run.rs -O -o target/guarded-run +./target/guarded-run --timeout-secs 1800 --heartbeat-secs 30 -- \ + cargo apk build --features android-gui --target aarch64-linux-android --lib +aapt dump permissions target/debug/apk/vimit.apk +``` + +`tools/guarded-run.rs` is a tiny Rust wrapper for long-running commands. It +prints heartbeat messages and exits with code `124` if the command exceeds the +timeout, which helps distinguish a real build hang from normal Android build +work. + +This is intentionally not a GitHub Release. Release tags and production +release assets stay under the existing release workflow. + ## Agent burn alerts The Android build declares `INTERNET`, `VIBRATE`, and `POST_NOTIFICATIONS`. diff --git a/tools/guarded-run.rs b/tools/guarded-run.rs new file mode 100644 index 0000000..1ef0f69 --- /dev/null +++ b/tools/guarded-run.rs @@ -0,0 +1,111 @@ +use std::env; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +fn main() { + let mut timeout_secs = 900_u64; + let mut heartbeat_secs = 30_u64; + let mut command_start = None; + let args: Vec = env::args().skip(1).collect(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--timeout-secs" => { + index += 1; + timeout_secs = parse_u64(args.get(index), "--timeout-secs"); + } + "--heartbeat-secs" => { + index += 1; + heartbeat_secs = parse_u64(args.get(index), "--heartbeat-secs"); + } + "--" => { + command_start = Some(index + 1); + break; + } + _ => { + command_start = Some(index); + break; + } + } + index += 1; + } + + let Some(command_start) = command_start else { + eprintln!( + "usage: guarded-run [--timeout-secs N] [--heartbeat-secs N] -- [args...]" + ); + std::process::exit(2); + }; + if command_start >= args.len() { + eprintln!("guarded-run: missing command"); + std::process::exit(2); + } + + let mut child = Command::new(&args[command_start]) + .args(&args[command_start + 1..]) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap_or_else(|error| { + eprintln!( + "guarded-run: cannot start '{}': {error}", + args[command_start] + ); + std::process::exit(127); + }); + + let started = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + let heartbeat = Duration::from_secs(heartbeat_secs.max(1)); + let mut next_heartbeat = heartbeat; + + loop { + match child.try_wait() { + Ok(Some(status)) => std::process::exit(status.code().unwrap_or(1)), + Ok(None) => {} + Err(error) => { + eprintln!("guarded-run: cannot read child status: {error}"); + let _ = child.kill(); + std::process::exit(1); + } + } + + let elapsed = started.elapsed(); + if elapsed >= timeout { + eprintln!( + "guarded-run: timeout after {}s; killing '{}'", + elapsed.as_secs(), + args[command_start] + ); + let _ = child.kill(); + let _ = child.wait(); + std::process::exit(124); + } + + if elapsed >= next_heartbeat { + eprintln!( + "guarded-run: still running '{}' after {}s (timeout {}s)", + args[command_start], + elapsed.as_secs(), + timeout_secs + ); + next_heartbeat += heartbeat; + } + + thread::sleep(Duration::from_secs(1)); + } +} + +fn parse_u64(value: Option<&String>, name: &str) -> u64 { + let Some(value) = value else { + eprintln!("guarded-run: missing value for {name}"); + std::process::exit(2); + }; + value.parse::().unwrap_or_else(|_| { + eprintln!("guarded-run: invalid integer for {name}: {value}"); + std::process::exit(2); + }) +}