Skip to content
Closed
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
80 changes: 80 additions & 0 deletions .github/workflows/android-test-apk.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 42 additions & 6 deletions docs/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.
Expand Down
111 changes: 111 additions & 0 deletions tools/guarded-run.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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] -- <command> [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::<u64>().unwrap_or_else(|_| {
eprintln!("guarded-run: invalid integer for {name}: {value}");
std::process::exit(2);
})
}
Loading