Repository: zng-ui/zng (language: rust)

Issue:
Implement Android view-process
Winit supports it, maybe we can extend `zng-view` to support Android instead of implementing a new crate.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0b1198d82..2356bde63 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,7 +76,52 @@ jobs:
         with:
           crate: wasm-pack
       - uses: Swatinem/rust-cache@v2
-      - run: cargo do run-wasm web --no-serve
+      - run: cargo do run-wasm multi --no-serve
+      - run: cargo clean
+  check-android:
+    runs-on: ubuntu-latest
+    needs: [check-ubuntu]
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:  
+          targets: aarch64-linux-android
+      - name: install cargo-ndk
+        uses: baptiste0928/cargo-install@v3
+        with:
+          crate: cargo-ndk
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo do build-apk multi
+      - run: cargo clean
+  check-android-windows:
+    runs-on: windows-latest
+    needs: [check-windows]
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:  
+          targets: aarch64-linux-android
+      - name: install cargo-ndk
+        uses: baptiste0928/cargo-install@v3
+        with:
+          crate: cargo-ndk
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo do build-apk multi
+      - run: cargo clean
+  check-android-macos:
+    runs-on: macos-latest
+    needs: [check-macos]
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:  
+          targets: aarch64-linux-android
+      - name: install cargo-ndk
+        uses: baptiste0928/cargo-install@v3
+        with:
+          crate: cargo-ndk
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo do build-apk multi
       - run: cargo clean
   doc:
     runs-on: ubuntu-latest
@@ -211,6 +256,21 @@ jobs:
 
   test-all:
     runs-on: ubuntu-latest
-    needs: [check-release, check-wasm, doc, test-ubuntu, test-windows, test-macos, test-doc, test-macro, test-render-ubuntu, test-render-windows, test-render-macos]
+    needs: [
+      check-release, 
+      check-wasm, 
+      check-android,
+      check-android-windows,
+      check-android-macos,
+      doc, 
+      test-ubuntu, 
+      test-windows, 
+      test-macos, 
+      test-doc, 
+      test-macro, 
+      test-render-ubuntu, 
+      test-render-windows, 
+      test-render-macos
+    ]
     steps:
       - run: exit 0
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 77c5403b4..85e1b70c1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -36,8 +36,11 @@
         "*.md.zr-rp": "markdown",
     },
     "cSpell.words": [
+        "aapt",
         "accesskit",
         "afrc",
+        "androiddebugkey",
+        "apksigner",
         "appa",
         "arboard",
         "attrs",
@@ -76,6 +79,7 @@
         "devocar",
         "dlig",
         "dlopen",
+        "dname",
         "docblock",
         "EINVAL",
         "eos",
@@ -92,6 +96,7 @@
         "fwid",
         "Fx",
         "GDEF",
+        "genkey",
         "glutin",
         "GSUB",
         "hgl",
@@ -106,6 +111,10 @@
         "ipmpsc",
         "isahc",
         "Jp",
+        "keyalg",
+        "keypass",
+        "keysize",
+        "keytool",
         "lft",
         "libfontconfig",
         "libglib",
@@ -114,6 +123,7 @@
         "liga",
         "linkme",
         "lnum",
+        "logcat",
         "machacek",
         "mexprp",
         "minidumper",
@@ -133,6 +143,7 @@
         "pathdiff",
         "pcap",
         "pct",
+        "pkcs",
         "pkna",
         "pnum",
         "Pombo",
@@ -167,6 +178,8 @@
         "sos",
         "splitmix",
         "ss",
+        "storepass",
+        "storetype",
         "structfield",
         "swgl",
         "swsh",
@@ -211,6 +224,7 @@
         "xshell",
         "Zalgo",
         "zdenek",
+        "zipalign",
         "zng"
     ],
     "lldb.launch.env": {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e2eafc66..9495cb849 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
 # Unreleased
 
+* Implement Android suspend/resume cycle using the existing "respawn" API.
+* Add `APP.is_suspended` var.
+* Add `VIEW_PROCESS_SUSPENDED_EVENT`.
+* `VIEW_PROCESS_INITED_EVENT` now notifies a "respawn" on resume after suspension.
+* **Breaking** Add `Event::Suspended`.
+    - Note that this is only a breaking change for direct dependents of `zng-view-api`.
+* Add `ViewExtension::suspended/resumed`.
+* Implement system fonts query for Android.
+* Implement conversions from `FontStyle`, `FontWeight` and  `FontStretch` to the `ttf-parser` equivalent types.
+* Implement `PartialOrd, Ord` for `FontName`.
+* Implemented Android `run_same_process` entry point.
+* Fixed Android build errors.
 * Fix gradient stops that mix positional stops with offset stops. 
 * Fix build in platforms without `AtomicU64`.
 * Fix `zng::env::bin` in Wasm builds.
diff --git a/crates/cargo-zng/Cargo.toml b/crates/cargo-zng/Cargo.toml
index b12a557b5..0b275109b 100644
--- a/crates/cargo-zng/Cargo.toml
+++ b/crates/cargo-zng/Cargo.toml
@@ -39,6 +39,8 @@ is_executable = "1.0"
 anyhow = "1.0"
 dunce = "1.0"
 zng-env = { path = "../zng-env", version = "0.3.2" }
+directories = "5.0"
+quick-xml = { version = "0.36", features = ["serialize"] }
 
 # fmt
 regex = "1.10"
diff --git a/crates/cargo-zng/README.md b/crates/cargo-zng/README.md
index ebbd676f3..bd81fd75a 100644
--- a/crates/cargo-zng/README.md
+++ b/crates/cargo-zng/README.md
@@ -478,6 +478,9 @@ $ cargo zng res --tools
 .zr-fail @ cargo-zng
   Print an error message and fail the build
 
+.zr-apk @ cargo-zng
+  Build an Android APK from the parent folder
+
 call 'cargo zng res --help tool' to read full help from a tool
 ```
 
diff --git a/crates/cargo-zng/src/res/built_in.rs b/crates/cargo-zng/src/res/built_in.rs
index d7437a8b2..1d3e8f13d 100644
--- a/crates/cargo-zng/src/res/built_in.rs
+++ b/crates/cargo-zng/src/res/built_in.rs
@@ -663,6 +663,332 @@ fn shf() {
     }
 }
 
+const APK_HELP: &str = r#"
+Build an Android APK from the parent folder
+
+The expected folder structure:
+
+| my-app.apk/
+| ├── lib/
+| |   └── arm64-v8a
+| |       └── my-app.so
+| ├── res/
+| ├── AndroidManifest.xml
+| └── build.zr-apk
+
+Will be replaced with the built my-app.apk
+
+Expected build.zr-apk file content:
+
+| # comment
+|
+| # sign using the debug key. Note that if ZR_APK_KEYSTORE or ZR_APK_KEY_ALIAS are not
+| # set the APK is also signed using the debug key
+| debug = true
+| # don't sign and don't zipalign the APK. This outputs an incomplete package that
+| # cannot be installed, but can be modified such as custom linking and signing
+| raw = true
+
+APK signing is configured using these environment variables:
+
+ZR_APK_KEYSTORE - path to the private .keystore file
+ZR_APK_KEYSTORE_PASS - keystore file password
+ZR_APK_KEY_ALIAS - key name in the keystore
+ZR_APK_KEY_PASS - key password
+"#;
+fn apk() {
+    help(APK_HELP);
+    if std::env::var(ZR_FINAL).is_err() {
+        println!("zng-res::on-final=");
+        return;
+    }
+
+    // read config
+    let mut debug = false;
+    let mut raw = false;
+    for line in read_lines(&path(ZR_REQUEST)) {
+        let (ln, line) = line.unwrap_or_else(|e| fatal!("error reading .zr-apk request, {e}"));
+        if let Some((key, value)) = line.split_once('=') {
+            let key = key.trim();
+            let value = value.trim();
+
+            let bool_value = || match value {
+                "true" => true,
+                "false" => false,
+                _ => {
+                    error!("unexpected value, line {ln}\n   {line}");
+                    false
+                }
+            };
+            match key {
+                "debug" => debug = bool_value(),
+                "raw" => raw = bool_value(),
+                _ => error!("unknown key, line {ln}\n   {line}"),
+            }
+        } else {
+            error!("syntax error, line {ln}\n{line}");
+        }
+    }
+    let mut keystore = PathBuf::from(env::var("ZR_APK_KEYSTORE").unwrap_or_default());
+    let mut keystore_pass = env::var("ZR_APK_KEYSTORE_PASS").unwrap_or_default();
+    let mut key_alias = env::var("ZR_APK_KEY_ALIAS").unwrap_or_default();
+    let mut key_pass = env::var("ZR_APK_KEY_PASS").unwrap_or_default();
+    if keystore.as_os_str().is_empty() || key_alias.is_empty() {
+        debug = true;
+    }
+
+    let apk_folder = path(ZR_TARGET_DD);
+    if apk_folder.extension().map(|s| s.to_ascii_lowercase() != "apk").unwrap_or(true) {
+        fatal!("not inside .apk folder")
+    }
+
+    // find <sdk>/build-tools
+    let android_home = match env::var("ANDROID_HOME") {
+        Ok(h) if !h.is_empty() => h,
+        _ => fatal!("please set ANDROID_HOME to the android-sdk dir"),
+    };
+    let build_tools = Path::new(&android_home).join("build-tools/");
+    let mut best_build = None;
+    let mut best_version = semver::Version::new(0, 0, 0);
+
+    #[cfg(not(windows))]
+    const AAPT2_NAME: &str = "aapt2";
+    #[cfg(windows)]
+    const AAPT2_NAME: &str = "aapt2.exe";
+
+    for dir in fs::read_dir(build_tools).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/, {e}")) {
+        let dir = dir
+            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/ entry, {e}"))
+            .path();
+
+        if let Some(ver) = dir
+            .file_name()
+            .and_then(|f| f.to_str())
+            .and_then(|f| semver::Version::parse(f).ok())
+        {
+            if ver > best_version && dir.join(AAPT2_NAME).exists() {
+                best_build = Some(dir);
+                best_version = ver;
+            }
+        }
+    }
+    let build_tools = match best_build {
+        Some(p) => p,
+        None => fatal!("cannot find $ANDROID_HOME/build-tools/<version>/{AAPT2_NAME}"),
+    };
+    let aapt2_path = build_tools.join(AAPT2_NAME);
+
+    // temp target dir
+    let temp_dir = apk_folder.with_extension("apk.tmp");
+    let _ = fs::remove_dir_all(&temp_dir);
+    fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
+
+    // build resources
+    let compiled_res = temp_dir.join("compiled_res.zip");
+    let res = apk_folder.join("res");
+    if res.exists() {
+        let mut aapt2 = Command::new(&aapt2_path);
+        aapt2.arg("compile").arg("-o").arg(&compiled_res).arg(res.join("*.xml"));
+
+        if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
+            fatal!("resources build failed");
+        }
+    }
+
+    let manifest_path = apk_folder.join("AndroidManifest.xml");
+    let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| fatal!("cannot read AndroidManifest.xml, {e}"));
+    let manifest: AndroidManifest = quick_xml::de::from_str(&manifest).unwrap_or_else(|e| fatal!("error parsing AndroidManifest.xml, {e}"));
+
+    // find <sdk>/platforms
+    let platforms = Path::new(&android_home).join("platforms");
+    let mut best_platform = None;
+    let mut best_version = 0;
+    for dir in fs::read_dir(platforms).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/, {e}")) {
+        let dir = dir
+            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/ entry, {e}"))
+            .path();
+
+        if let Some(ver) = dir
+            .file_name()
+            .and_then(|f| f.to_str())
+            .and_then(|f| f.strip_prefix("android-"))
+            .and_then(|f| f.parse().ok())
+        {
+            if manifest.uses_sdk.matches(ver) && ver > best_version && dir.join("android.jar").exists() {
+                best_platform = Some(dir);
+                best_version = ver;
+            }
+        }
+    }
+    let platform = match best_platform {
+        Some(p) => p,
+        None => fatal!("cannot find $ANDROID_HOME/platforms/<version>/android.jar"),
+    };
+
+    // make apk (link)
+    let apk_path = temp_dir.join("output.apk");
+    let mut aapt2 = Command::new(&aapt2_path);
+    aapt2
+        .arg("link")
+        .arg("-o")
+        .arg(&apk_path)
+        .arg("--manifest")
+        .arg(manifest_path)
+        .arg("-I")
+        .arg(platform.join("android.jar"));
+    if compiled_res.exists() {
+        aapt2.arg(&compiled_res);
+    }
+    if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
+        fatal!("apk linking failed");
+    }
+
+    // add libs
+    let aapt_path = build_tools.join("aapt");
+    for lib in glob::glob(apk_folder.join("lib/*/*.so").display().to_string().as_str()).unwrap() {
+        let lib = lib.unwrap_or_else(|e| fatal!("error searching libs, {e}"));
+
+        let lib = lib.display().to_string().replace('\\', "/");
+        let lib = &lib[lib.rfind("/lib/").unwrap() + 1..];
+
+        let mut aapt = Command::new(&aapt_path);
+        aapt.arg("add").arg(&apk_path).arg(lib).current_dir(&apk_folder);
+        if aapt.status().map(|s| !s.success()).unwrap_or(true) {
+            fatal!("apk linking failed");
+        }
+    }
+
+    let final_apk = if raw {
+        apk_path
+    } else {
+        // align
+        let aligned_apk_path = temp_dir.join("output-aligned.apk");
+        let zipalign_path = build_tools.join("zipalign");
+        let mut zipalign = Command::new(zipalign_path);
+        zipalign.arg("-v").arg("4").arg(apk_path).arg(&aligned_apk_path);
+        if zipalign.status().map(|s| !s.success()).unwrap_or(true) {
+            fatal!("zipalign failed");
+        }
+
+        // sign
+        let signed_apk_path = temp_dir.join("output-signed.apk");
+        if debug {
+            let dirs = directories::BaseDirs::new().unwrap_or_else(|| fatal!("cannot fine $HOME"));
+            keystore = dirs.home_dir().join(".android/debug.keystore");
+            keystore_pass = "android".to_owned();
+            key_alias = "androiddebugkey".to_owned();
+            key_pass = "android".to_owned();
+            if !keystore.exists() {
+                // generate debug.keystore
+                let _ = fs::create_dir_all(keystore.parent().unwrap());
+                let keytool_path = Path::new(&env::var("JAVA_HOME").expect("please set JAVA_HOME")).join("bin/keytool");
+                let mut keytool = Command::new(&keytool_path);
+                keytool
+                    .arg("-genkey")
+                    .arg("-v")
+                    .arg("-keystore")
+                    .arg(&keystore)
+                    .arg("-storepass")
+                    .arg(&keystore_pass)
+                    .arg("-alias")
+                    .arg(&key_alias)
+                    .arg("-keypass")
+                    .arg(&key_pass)
+                    .arg("-keyalg")
+                    .arg("RSA")
+                    .arg("-keysize")
+                    .arg("2048")
+                    .arg("-validity")
+                    .arg("10000")
+                    .arg("-dname")
+                    .arg("CN=Android Debug,O=Android,C=US")
+                    .arg("-storetype")
+                    .arg("pkcs12");
+
+                match keytool.status() {
+                    Ok(s) => {
+                        if !s.success() {
+                            fatal!("keytool failed generating debug keys");
+                        }
+                    }
+                    Err(e) => fatal!("cannot run '{}', {e}", keytool_path.display()),
+                }
+            }
+        }
+
+        #[cfg(not(windows))]
+        const APKSIGNER_NAME: &str = "apksigner";
+        #[cfg(windows)]
+        const APKSIGNER_NAME: &str = "apksigner.bat";
+
+        let apksigner_path = build_tools.join(APKSIGNER_NAME);
+        let mut apksigner = Command::new(&apksigner_path);
+        apksigner
+            .arg("sign")
+            .arg("--ks")
+            .arg(keystore)
+            .arg("--ks-pass")
+            .arg(format!("pass:{keystore_pass}"))
+            .arg("--ks-key-alias")
+            .arg(key_alias)
+            .arg("--key-pass")
+            .arg(format!("pass:{key_pass}"))
+            .arg("--out")
+            .arg(&signed_apk_path)
+            .arg(&aligned_apk_path);
+
+        match apksigner.status() {
+            Ok(s) => {
+                if !s.success() {
+                    fatal!("apksigner failed")
+                }
+            }
+            Err(e) => fatal!("cannot run '{}', {e}", apksigner_path.display()),
+        }
+        signed_apk_path
+    };
+
+    // finalize
+    fs::remove_dir_all(&apk_folder).unwrap_or_else(|e| fatal!("apk folder cleanup failed, {e}"));
+    fs::rename(final_apk, apk_folder).unwrap_or_else(|e| fatal!("cannot copy built apk to final place, {e}"));
+    fs::remove_dir_all(&temp_dir).unwrap_or_else(|e| fatal!("temp dir cleanup failed, {e}"));
+}
+#[derive(serde::Deserialize)]
+#[serde(rename = "manifest")]
+struct AndroidManifest {
+    #[serde(rename = "uses-sdk")]
+    #[serde(default)]
+    pub uses_sdk: AndroidSdk,
+}
+#[derive(Default, serde::Deserialize)]
+#[serde(rename = "uses-sdk")]
+struct AndroidSdk {
+    #[serde(rename(serialize = "android:minSdkVersion"))]
+    pub min_sdk_version: Option<u32>,
+    #[serde(rename(serialize = "android:targetSdkVersion"))]
+    pub target_sdk_version: Option<u32>,
+    #[serde(rename(serialize = "android:maxSdkVersion"))]
+    pub max_sdk_version: Option<u32>,
+}
+impl AndroidSdk {
+    pub fn matches(&self, version: u32) -> bool {
+        if let Some(v) = self.target_sdk_version {
+            return v == version;
+        }
+        if let Some(m) = self.min_sdk_version {
+            if version < m {
+                return false;
+            }
+        }
+        if let Some(m) = self.max_sdk_version {
+            if version > m {
+                return false;
+            }
+        }
+        true
+    }
+}
+
 fn read_line(path: &Path, expected: &str) -> io::Result<String> {
     match read_lines(path).next() {
         Some(r) => r.map(|(_, l)| l),
@@ -767,6 +1093,7 @@ built_in! {
     shf,
     warn,
     fail,
+    apk,
 }
 
 pub fn run() {
diff --git a/crates/zng-app/Cargo.toml b/crates/zng-app/Cargo.toml
index 872e440d8..40bfb1c1b 100644
--- a/crates/zng-app/Cargo.toml
+++ b/crates/zng-app/Cargo.toml
@@ -57,7 +57,7 @@ trace_wgt_item = []
 
 # Allow app-process crash handler.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 crash_handler = [
     "dep:serde_json",
     "dep:minidumper",
@@ -114,7 +114,7 @@ rustc-hash = "2.0"
 
 dunce = "1.0"
 
-[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+[target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies]
 serde_json = { version = "1.0", optional = true }
 minidumper = { version = "0.8", optional = true }
 crash-handler = { version = "0.6", optional = true }
@@ -125,6 +125,9 @@ uuid = { version = "1.1", optional = true }
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 tracing-web = "0.1"
 
+[target.'cfg(target_os = "android")'.dependencies]
+tracing-android = "0.2"
+
 [lints.rust]
 unexpected_cfgs = { level = "warn", check-cfg = ['cfg(never)'] }
 
diff --git a/crates/zng-app/README.md b/crates/zng-app/README.md
index 0e7445350..6f7853bff 100644
--- a/crates/zng-app/README.md
+++ b/crates/zng-app/README.md
@@ -52,7 +52,7 @@ Note that this can cause very large trace files and bad performance.
 #### `"crash_handler"`
 Allow app-process crash handler.
 
-Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 
 #### `"ipc"`
 Enables IPC tasks and pre-build views and connecting to views running in another process.
diff --git a/crates/zng-app/src/crash_handler.rs b/crates/zng-app/src/crash_handler.rs
index e11637859..14104ca2b 100644
--- a/crates/zng-app/src/crash_handler.rs
+++ b/crates/zng-app/src/crash_handler.rs
@@ -1,4 +1,4 @@
-#![cfg(all(feature = "crash_handler", not(target_arch = "wasm32")))]
+#![cfg(all(feature = "crash_handler", not(any(target_arch = "wasm32", target_os = "android"))))]
 
 //! App-process crash handler.
 //!
diff --git a/crates/zng-app/src/lib.rs b/crates/zng-app/src/lib.rs
index 24d378de8..c32057c5d 100644
--- a/crates/zng-app/src/lib.rs
+++ b/crates/zng-app/src/lib.rs
@@ -1408,6 +1408,8 @@ mod private {
 ///
 /// In `"wasm32"` builds logs to the browser console.
 ///
+/// In `"android"` builds logs to logcat.
+///
 /// See also [`test_log`] to enable panicking on error log.
 ///
 /// See also [`print_tracing_filter`] for the filter used by this.
@@ -1416,16 +1418,22 @@ mod private {
 pub fn print_tracing(max: tracing::Level) -> bool {
     use tracing_subscriber::prelude::*;
 
-    let fmt_layer = tracing_subscriber::fmt::layer().without_time();
+    let layers = tracing_subscriber::registry().with(FilterLayer(max));
+
+    #[cfg(target_os = "android")]
+    let layers = layers.with(tracing_android::layer(&zng_env::about().pkg_name).unwrap());
+
+    #[cfg(not(target_os = "android"))]
+    let layers = {
+        let fmt_layer = tracing_subscriber::fmt::layer().without_time();
 
-    #[cfg(target_arch = "wasm32")]
-    let fmt_layer = fmt_layer.with_ansi(false).with_writer(tracing_web::MakeWebConsoleWriter::new());
+        #[cfg(target_arch = "wasm32")]
+        let fmt_layer = fmt_layer.with_ansi(false).with_writer(tracing_web::MakeWebConsoleWriter::new());
+
+        layers.with(fmt_layer)
+    };
 
-    tracing_subscriber::registry()
-        .with(FilterLayer(max))
-        .with(fmt_layer)
-        .try_init()
-        .is_ok()
+    layers.try_init().is_ok()
 }
 
 struct FilterLayer(tracing::Level);
diff --git a/crates/zng-app/src/running.rs b/crates/zng-app/src/running.rs
index 348d2e941..c944c173c 100644
--- a/crates/zng-app/src/running.rs
+++ b/crates/zng-app/src/running.rs
@@ -13,7 +13,7 @@ use zng_app_context::{app_local, AppScope};
 use zng_task::DEADLINE_APP;
 use zng_time::{InstantMode, INSTANT_APP};
 use zng_txt::Txt;
-use zng_var::{response_var, ArcVar, ResponderVar, ResponseVar, Var as _, VARS, VARS_APP};
+use zng_var::{response_var, ArcVar, ReadOnlyArcVar, ResponderVar, ResponseVar, Var as _, VARS, VARS_APP};
 
 use crate::{
     event::{
@@ -499,7 +499,9 @@ impl<E: AppExtension> RunningApp<E> {
             }
 
             // Others
-            Event::Inited(zng_view_api::Inited { .. }) | Event::Disconnected(_) | Event::FrameRendered(_) => unreachable!(), // handled before coalesce.
+            Event::Inited(zng_view_api::Inited { .. }) | Event::Suspended | Event::Disconnected(_) | Event::FrameRendered(_) => {
+                unreachable!()
+            } // handled before coalesce.
         }
     }
 
@@ -572,6 +574,7 @@ impl<E: AppExtension> RunningApp<E> {
                     // notify immediately.
                     if is_respawn {
                         VIEW_PROCESS.on_respawned(generation);
+                        APP_PROCESS_SV.read().is_suspended.set(false);
                     }
 
                     VIEW_PROCESS.handle_inited(generation, extensions.clone());
@@ -598,6 +601,12 @@ impl<E: AppExtension> RunningApp<E> {
                     );
                     self.notify_event(VIEW_PROCESS_INITED_EVENT.new_update(args), observer);
                 }
+                zng_view_api::Event::Suspended => {
+                    VIEW_PROCESS.handle_suspended();
+                    let args = crate::view_process::ViewProcessSuspendedArgs::now();
+                    self.notify_event(VIEW_PROCESS_SUSPENDED_EVENT.new_update(args), observer);
+                    APP_PROCESS_SV.read().is_suspended.set(true);
+                }
                 zng_view_api::Event::Disconnected(gen) => {
                     // update ViewProcess immediately.
                     VIEW_PROCESS.handle_disconnect(gen);
@@ -1086,6 +1095,17 @@ impl APP {
     pub fn exit(&self) -> ResponseVar<ExitCancelled> {
         APP_PROCESS_SV.write().exit()
     }
+
+    /// Gets a variable that tracks if the app is suspended by the operating system.
+    ///
+    /// Suspended apps cannot create graphics contexts and are likely to be killed if the user does not
+    /// return. Operations that persist data should flush on suspension.
+    ///
+    /// App suspension is controlled by the view-process, the [`VIEW_PROCESS_SUSPENDED_EVENT`] notifies
+    /// on suspension and the [`VIEW_PROCESS_INITED_EVENT`] notifies a "respawn" on resume.
+    pub fn is_suspended(&self) -> ReadOnlyArcVar<bool> {
+        APP_PROCESS_SV.read().is_suspended.read_only()
+    }
 }
 
 /// App time control.
@@ -1326,6 +1346,7 @@ app_local! {
         extensions: None,
         device_events: false,
         pause_time_for_updates: zng_var::var(true),
+        is_suspended: zng_var::var(false),
     };
 }
 
@@ -1334,6 +1355,7 @@ pub(super) struct AppProcessService {
     extensions: Option<Arc<AppExtensionsInfo>>,
     pub(super) device_events: bool,
     pause_time_for_updates: ArcVar<bool>,
+    is_suspended: ArcVar<bool>,
 }
 impl AppProcessService {
     pub(super) fn take_requests(&mut self) -> Option<ResponderVar<ExitCancelled>> {
@@ -1578,8 +1600,8 @@ event! {
     /// Cancellable event raised when app process exit is requested.
     ///
     /// App exit can be requested using the [`APP`] service or the [`EXIT_CMD`], some extensions
-    /// also request exit if some conditions are met, `WindowManager` requests it after the last window
-    /// is closed for example.
+    /// also request exit if some conditions are met, for example, `WindowManager` requests it after the last window
+    /// is closed.
     ///
     /// Requesting `propagation().stop()` on this event cancels the exit.
     pub static EXIT_REQUESTED_EVENT: ExitRequestedArgs;
diff --git a/crates/zng-app/src/view_process.rs b/crates/zng-app/src/view_process.rs
index 7a9a57791..61ff0f636 100644
--- a/crates/zng-app/src/view_process.rs
+++ b/crates/zng-app/src/view_process.rs
@@ -361,6 +361,10 @@ impl VIEW_PROCESS {
         me.process.handle_inited(gen);
     }
 
+    pub(super) fn handle_suspended(&self) {
+        self.write().process.handle_suspended();
+    }
+
     pub(crate) fn on_headless_opened(
         &self,
         id: WindowId,
@@ -562,6 +566,8 @@ event_args! {
 
         /// If this is not the first time a view-process was inited. If `true`
         /// all resources created in a previous generation must be rebuilt.
+        ///
+        /// This can happen after a view-process crash or app suspension.
         pub is_respawn: bool,
 
         /// Monitors list.
@@ -600,11 +606,27 @@ event_args! {
             list.search_all()
         }
     }
+
+    /// Arguments for the [`VIEW_PROCESS_SUSPENDED_EVENT`].
+    pub struct ViewProcessSuspendedArgs {
+
+        ..
+
+        /// Broadcast to all widgets.
+        fn delivery_list(&self, list: &mut UpdateDeliveryList) {
+            list.search_all()
+        }
+    }
 }
 
 event! {
-    /// View Process finished initializing and is now online.
+    /// View-Process finished initializing and is now online.
     pub static VIEW_PROCESS_INITED_EVENT: ViewProcessInitedArgs;
+    /// View-Process suspended, all resources dropped.
+    ///
+    /// The view-process will only be available if the app resumes. On resume [`VIEW_PROCESS_INITED_EVENT`]
+    /// notify a view-process respawn.
+    pub static VIEW_PROCESS_SUSPENDED_EVENT: ViewProcessSuspendedArgs;
 }
 
 /// Information about a successfully opened window.
diff --git a/crates/zng-ext-font/Cargo.toml b/crates/zng-ext-font/Cargo.toml
index b70257780..f993c3f79 100644
--- a/crates/zng-ext-font/Cargo.toml
+++ b/crates/zng-ext-font/Cargo.toml
@@ -50,7 +50,7 @@ smallvec = { version = "1.13", features = ["const_new", "serde"] }
 hashbrown = "0.14"
 pathfinder_geometry = "0.5"
 
-[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+[target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies]
 font-kit = "0.14"
 
 [package.metadata.docs.rs]
diff --git a/crates/zng-ext-font/src/lib.rs b/crates/zng-ext-font/src/lib.rs
index f397d38b2..2c4a9f22a 100644
--- a/crates/zng-ext-font/src/lib.rs
+++ b/crates/zng-ext-font/src/lib.rs
@@ -78,18 +78,18 @@ use zng_view_api::{config::FontAntiAliasing, ViewProcessOffline};
 /// Font family names are case-insensitive. `"Arial"` and `"ARIAL"` are equal and have the same hash.
 #[derive(Clone)]
 pub struct FontName {
-    text: Txt,
+    txt: Txt,
     is_ascii: bool,
 }
 impl fmt::Debug for FontName {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         if f.alternate() {
             f.debug_struct("FontName")
-                .field("text", &self.text)
+                .field("txt", &self.txt)
                 .field("is_ascii", &self.is_ascii)
                 .finish()
         } else {
-            write!(f, "{:?}", self.text)
+            write!(f, "{:?}", self.txt)
         }
     }
 }
@@ -109,6 +109,20 @@ impl std::hash::Hash for FontName {
         std::hash::Hash::hash(&self.unicase(), state)
     }
 }
+impl Ord for FontName {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        if self == other {
+            // case insensitive eq
+            return std::cmp::Ordering::Equal;
+        }
+        self.txt.cmp(&other.txt)
+    }
+}
+impl PartialOrd for FontName {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
 impl FontName {
     fn unicase(&self) -> unicase::UniCase<&str> {
         if self.is_ascii {
@@ -121,7 +135,7 @@ impl FontName {
     /// New font name from `&'static str`.
     pub const fn from_static(name: &'static str) -> Self {
         FontName {
-            text: Txt::from_static(name),
+            txt: Txt::from_static(name),
             is_ascii: {
                 // str::is_ascii is not const
                 let name_bytes = name.as_bytes();
@@ -148,10 +162,10 @@ impl FontName {
     ///
     /// [`Txt`]: zng_txt::Txt
     pub fn new(name: impl Into<Txt>) -> Self {
-        let text = name.into();
+        let txt = name.into();
         FontName {
-            is_ascii: text.is_ascii(),
-            text,
+            is_ascii: txt.is_ascii(),
+            txt,
         }
     }
 
@@ -194,14 +208,14 @@ impl FontName {
 
     /// Reference the font name string.
     pub fn name(&self) -> &str {
-        &self.text
+        &self.txt
     }
 
     /// Unwraps into a [`Txt`].
     ///
     /// [`Txt`]: zng_txt::Txt
     pub fn into_text(self) -> Txt {
-        self.text
+        self.txt
     }
 }
 impl_from_and_into_var! {
@@ -227,12 +241,12 @@ impl std::ops::Deref for FontName {
     type Target = str;
 
     fn deref(&self) -> &Self::Target {
-        self.text.deref()
+        self.txt.deref()
     }
 }
 impl AsRef<str> for FontName {
     fn as_ref(&self) -> &str {
-        self.text.as_ref()
+        self.txt.as_ref()
     }
 }
 impl serde::Serialize for FontName {
@@ -240,7 +254,7 @@ impl serde::Serialize for FontName {
     where
         S: serde::Serializer,
     {
-        self.text.serialize(serializer)
+        self.txt.serialize(serializer)
     }
 }
 impl<'de> serde::Deserialize<'de> for FontName {
@@ -2555,7 +2569,6 @@ impl_from_and_into_var! {
         FontStretch(fct)
     }
 }
-
 impl From<ttf_parser::Width> for FontStretch {
     fn from(value: ttf_parser::Width) -> Self {
         use ttf_parser::Width::*;
@@ -2572,6 +2585,29 @@ impl From<ttf_parser::Width> for FontStretch {
         }
     }
 }
+impl From<FontStretch> for ttf_parser::Width {
+    fn from(value: FontStretch) -> Self {
+        if value <= FontStretch::ULTRA_CONDENSED {
+            ttf_parser::Width::UltraCondensed
+        } else if value <= FontStretch::EXTRA_CONDENSED {
+            ttf_parser::Width::ExtraCondensed
+        } else if value <= FontStretch::CONDENSED {
+            ttf_parser::Width::Condensed
+        } else if value <= FontStretch::SEMI_CONDENSED {
+            ttf_parser::Width::SemiCondensed
+        } else if value <= FontStretch::NORMAL {
+            ttf_parser::Width::Normal
+        } else if value <= FontStretch::SEMI_EXPANDED {
+            ttf_parser::Width::SemiExpanded
+        } else if value <= FontStretch::EXPANDED {
+            ttf_parser::Width::Expanded
+        } else if value <= FontStretch::EXTRA_EXPANDED {
+            ttf_parser::Width::ExtraExpanded
+        } else {
+            ttf_parser::Width::UltraExpanded
+        }
+    }
+}
 
 /// The italic or oblique form of a font.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
@@ -2607,6 +2643,16 @@ impl From<ttf_parser::Style> for FontStyle {
     }
 }
 
+impl From<FontStyle> for ttf_parser::Style {
+    fn from(value: FontStyle) -> Self {
+        match value {
+            FontStyle::Normal => Self::Normal,
+            FontStyle::Italic => Self::Italic,
+            FontStyle::Oblique => Self::Oblique,
+        }
+    }
+}
+
 /// The degree of stroke thickness of a font. This value ranges from 100.0 to 900.0,
 /// with 400.0 as normal.
 #[derive(Clone, Copy, Transitionable, serde::Serialize, serde::Deserialize)]
@@ -2712,6 +2758,11 @@ impl From<ttf_parser::Weight> for FontWeight {
         }
     }
 }
+impl From<FontWeight> for ttf_parser::Weight {
+    fn from(value: FontWeight) -> Self {
+        ttf_parser::Weight::from(value.0 as u16)
+    }
+}
 
 /// Configuration of text wrapping for Chinese, Japanese, or Korean text.
 #[derive(Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
diff --git a/crates/zng-ext-font/src/query_util.rs b/crates/zng-ext-font/src/query_util.rs
index fe83f1c6c..e17c7adf1 100644
--- a/crates/zng-ext-font/src/query_util.rs
+++ b/crates/zng-ext-font/src/query_util.rs
@@ -1,37 +1,19 @@
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
 pub use desktop::*;
 
 #[cfg(target_arch = "wasm32")]
 pub use wasm::*;
 
-#[cfg(target_arch = "wasm32")]
-mod wasm {
-    use zng_var::ResponseVar;
-
-    use crate::{FontDataRef, FontLoadingError, FontName, FontStretch, FontStyle, FontWeight};
-
-    pub fn system_all() -> ResponseVar<Vec<FontName>> {
-        zng_var::response_done_var(vec![])
-    }
+#[cfg(target_os = "android")]
+pub use android::*;
 
-    pub fn best(
-        font_name: &FontName,
-        style: FontStyle,
-        weight: FontWeight,
-        stretch: FontStretch,
-    ) -> Result<Option<(FontDataRef, u32)>, FontLoadingError> {
-        let _ = (font_name, style, weight, stretch);
-        Err(FontLoadingError::NoFilesystem)
-    }
-}
-
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
 mod desktop {
     use std::{borrow::Cow, path::Path, sync::Arc};
 
     use zng_var::ResponseVar;
 
-    use crate::{FontDataRef, FontLoadingError, FontName, FontStretch, FontStyle, FontWeight};
+    use crate::{FontDataRef, FontLoadingError, FontName, FontStretch, FontStyle, FontWeight, GlyphLoadingError};
 
     pub fn system_all() -> ResponseVar<Vec<FontName>> {
         zng_task::wait_respond(|| {
@@ -213,7 +195,7 @@ mod desktop {
                 "monospace" => Monospace,
                 "cursive" => Cursive,
                 "fantasy" => Fantasy,
-                _ => Title(font_name.text.into()),
+                _ => Title(font_name.txt.into()),
             }
         }
     }
@@ -240,4 +222,319 @@ mod desktop {
             font_kit::properties::Weight(value.0)
         }
     }
+
+    impl From<font_kit::error::GlyphLoadingError> for GlyphLoadingError {
+        fn from(value: font_kit::error::GlyphLoadingError) -> Self {
+            use GlyphLoadingError::*;
+            match value {
+                font_kit::error::GlyphLoadingError::NoSuchGlyph => NoSuchGlyph,
+                font_kit::error::GlyphLoadingError::PlatformError => PlatformError,
+            }
+        }
+    }
+}
+
+#[cfg(target_arch = "wasm32")]
+mod wasm {
+    use zng_var::ResponseVar;
+
+    use crate::{FontDataRef, FontLoadingError, FontName, FontStretch, FontStyle, FontWeight};
+
+    pub fn system_all() -> ResponseVar<Vec<FontName>> {
+        zng_var::response_done_var(vec![])
+    }
+
+    pub fn best(
+        font_name: &FontName,
+        style: FontStyle,
+        weight: FontWeight,
+        stretch: FontStretch,
+    ) -> Result<Option<(FontDataRef, u32)>, FontLoadingError> {
+        let _ = (font_name, style, weight, stretch);
+        Err(FontLoadingError::NoFilesystem)
+    }
+}
+
+#[cfg(target_os = "android")]
+mod android {
+    // font-kit does not cross compile for Android because of a dependency,
+    // so we reimplement/copy some of their code here.
+
+    use std::{borrow::Cow, path::PathBuf, sync::Arc};
+
+    use zng_var::ResponseVar;
+
+    use crate::{FontDataRef, FontLoadingError, FontName, FontStretch, FontStyle, FontWeight};
+
+    pub fn system_all() -> ResponseVar<Vec<FontName>> {
+        zng_task::wait_respond(|| {
+            let mut prev = None;
+            cached_system_all()
+                .iter()
+                .flat_map(|(k, _)| {
+                    if prev == Some(k) {
+                        None
+                    } else {
+                        prev = Some(k);
+                        Some(k)
+                    }
+                })
+                .cloned()
+                .collect()
+        })
+    }
+
+    fn cached_system_all() -> parking_lot::MappedRwLockReadGuard<'static, Vec<(FontName, PathBuf)>> {
+        let lock = SYSTEM_ALL.read();
+        if !lock.is_empty() {
+            return lock;
+        }
+
+        drop(lock);
+        let mut lock = SYSTEM_ALL.write();
+        if lock.is_empty() {
+            for entry in ["/system/fonts/", "/system/font/", "/data/fonts/", "/system/product/fonts/"]
+                .iter()
+                .flat_map(std::fs::read_dir)
+                .flatten()
+                .flatten()
+            {
+                let entry = entry.path();
+                let ext = entry.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
+                if ["ttf", "otf"].contains(&ext.as_str()) && entry.is_file() {
+                    if let Ok(bytes) = std::fs::read(&entry) {
+                        match crate::FontFace::load(FontDataRef(Arc::new(bytes)), 0) {
+                            Ok(f) => {
+                                lock.push((f.family_name().clone(), entry));
+                            }
+                            Err(e) => tracing::error!("error parsing '{}', {e}", entry.display()),
+                        }
+                    }
+                }
+            }
+            lock.sort_by(|a, b| a.0.cmp(&b.0));
+        }
+        drop(lock);
+        SYSTEM_ALL.read()
+    }
+
+    pub fn best(
+        font_name: &FontName,
+        style: FontStyle,
+        weight: FontWeight,
+        stretch: FontStretch,
+    ) -> Result<Option<(FontDataRef, u32)>, FontLoadingError> {
+        let lock = cached_system_all();
+        let lock = &*lock;
+
+        // special names
+        // source: https://android.googlesource.com/platform/frameworks/base/+/master/data/fonts/fonts.xml
+        let font_name = match font_name.name() {
+            "sans-serif" => Cow::Owned(FontName::new("Noto Sans")),
+            "serif" | "fantasy" => Cow::Owned(FontName::new("Noto Serif")),
+            "cursive" => Cow::Owned(FontName::new("Dancing Script")),
+            "monospace" => Cow::Owned(FontName::new("Droid Sans Mono")),
+            _ => Cow::Borrowed(font_name),
+        };
+        let font_name = &*font_name;
+
+        let mut start_i = match lock.binary_search_by(|a| a.0.cmp(font_name)) {
+            Ok(i) => i,
+            Err(_) => {
+                tracing::debug!(target: "font_loading", "system font not found\nquery: {:?}", (font_name, style, weight, stretch));
+                return Ok(None);
+            }
+        };
+        while start_i > 0 && &lock[start_i - 1].0 == font_name {
+            start_i -= 1
+        }
+        let mut end_i = start_i;
+        while end_i + 1 < lock.len() && &lock[end_i + 1].0 == font_name {
+            end_i += 1
+        }
+
+        let family_len = end_i - start_i;
+        let mut options = Vec::with_capacity(family_len);
+        let mut candidates = Vec::with_capacity(family_len);
+
+        for (_, path) in &lock[start_i..=end_i] {
+            if let Ok(bytes) = std::fs::read(path) {
+                if let Ok(f) = ttf_parser::Face::parse(&bytes, 0) {
+                    candidates.push(matching::Properties {
+                        style: f.style(),
+                        weight: f.weight(),
+                        stretch: f.width(),
+                    });
+                    options.push(bytes);
+                }
+            }
+        }
+
+        match matching::find_best_match(
+            &candidates,
+            &matching::Properties {
+                style: style.into(),
+                weight: weight.into(),
+                stretch: stretch.into(),
+            },
+        ) {
+            Ok(i) => {
+                let bytes = options.swap_remove(i);
+                Ok(Some((FontDataRef(Arc::new(bytes)), 0)))
+            }
+            Err(FontLoadingError::NoSuchFontInCollection) => {
+                tracing::debug!(target: "font_loading", "system font not found\nquery: {:?}", (font_name, style, weight, stretch));
+                Ok(None)
+            }
+            Err(e) => Err(FontLoadingError::Io(Arc::new(std::io::Error::new(std::io::ErrorKind::Other, e)))),
+        }
+    }
+
+    zng_app_context::app_local! {
+        static SYSTEM_ALL: Vec<(FontName, PathBuf)> = vec![];
+    }
+
+    mod matching {
+        // font-kit/src/matching.rs
+        //
+        // Copyright © 2018 The Pathfinder Project Developers.
+        //
+        // Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
+        // http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+        // <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
+        // option. This file may not be copied, modified, or distributed
+        // except according to those terms.
+
+        //! Determines the closest font matching a description per the CSS Fonts Level 3 specification.
+
+        use ttf_parser::{Style, Weight, Width as Stretch};
+
+        use crate::FontLoadingError;
+
+        pub struct Properties {
+            pub style: Style,
+            pub weight: Weight,
+            pub stretch: Stretch,
+        }
+
+        /// This follows CSS Fonts Level 3 § 5.2 [1].
+        ///
+        /// https://drafts.csswg.org/css-fonts-3/#font-style-matching
+        pub fn find_best_match(candidates: &[Properties], query: &Properties) -> Result<usize, FontLoadingError> {
+            // Step 4.
+            let mut matching_set: Vec<usize> = (0..candidates.len()).collect();
+            if matching_set.is_empty() {
+                return Err(FontLoadingError::NoSuchFontInCollection);
+            }
+
+            // Step 4a (`font-stretch`).
+            let matching_stretch = if matching_set.iter().any(|&index| candidates[index].stretch == query.stretch) {
+                // Exact match.
+                query.stretch
+            } else if query.stretch <= Stretch::Normal {
+                // Closest width, first checking narrower values and then wider values.
+                match matching_set
+                    .iter()
+                    .filter(|&&index| candidates[index].stretch < query.stretch)
+                    .min_by_key(|&&index| query.stretch.to_number() - candidates[index].stretch.to_number())
+                {
+                    Some(&matching_index) => candidates[matching_index].stretch,
+                    None => {
+                        let matching_index = *matching_set
+                            .iter()
+                            .min_by_key(|&&index| candidates[index].stretch.to_number() - query.stretch.to_number())
+                            .unwrap();
+                        candidates[matching_index].stretch
+                    }
+                }
+            } else {
+                // Closest width, first checking wider values and then narrower values.
+                match matching_set
+                    .iter()
+                    .filter(|&&index| candidates[index].stretch > query.stretch)
+                    .min_by_key(|&&index| candidates[index].stretch.to_number() - query.stretch.to_number())
+                {
+                    Some(&matching_index) => candidates[matching_index].stretch,
+                    None => {
+                        let matching_index = *matching_set
+                            .iter()
+                            .min_by_key(|&&index| query.stretch.to_number() - candidates[index].stretch.to_number())
+                            .unwrap();
+                        candidates[matching_index].stretch
+                    }
+                }
+            };
+            matching_set.retain(|&index| candidates[index].stretch == matching_stretch);
+
+            // Step 4b (`font-style`).
+            let style_preference = match query.style {
+                Style::Italic => [Style::Italic, Style::Oblique, Style::Normal],
+                Style::Oblique => [Style::Oblique, Style::Italic, Style::Normal],
+                Style::Normal => [Style::Normal, Style::Oblique, Style::Italic],
+            };
+            let matching_style = *style_preference
+                .iter()
+                .find(|&query_style| matching_set.iter().any(|&index| candidates[index].style == *query_style))
+                .unwrap();
+            matching_set.retain(|&index| candidates[index].style == matching_style);
+
+            // Step 4c (`font-weight`).
+            //
+            // The spec doesn't say what to do if the weight is between 400 and 500 exclusive, so we
+            // just use 450 as the cutoff.
+            let matching_weight = if matching_set.iter().any(|&index| candidates[index].weight == query.weight) {
+                query.weight
+            } else if query.weight.to_number() >= 400
+                && query.weight.to_number() < 450
+                && matching_set.iter().any(|&index| candidates[index].weight == Weight::from(500))
+            {
+                // Check 500 first.
+                Weight::from(500)
+            } else if query.weight.to_number() >= 450
+                && query.weight.to_number() <= 500
+                && matching_set.iter().any(|&index| candidates[index].weight.to_number() == 400)
+            {
+                // Check 400 first.
+                Weight::from(400)
+            } else if query.weight.to_number() <= 500 {
+                // Closest weight, first checking thinner values and then fatter ones.
+                match matching_set
+                    .iter()
+                    .filter(|&&index| candidates[index].weight.to_number() <= query.weight.to_number())
+                    .min_by_key(|&&index| query.weight.to_number() - candidates[index].weight.to_number())
+                {
+                    Some(&matching_index) => candidates[matching_index].weight,
+                    None => {
+                        let matching_index = *matching_set
+                            .iter()
+                            .min_by_key(|&&index| candidates[index].weight.to_number() - query.weight.to_number())
+                            .unwrap();
+                        candidates[matching_index].weight
+                    }
+                }
+            } else {
+                // Closest weight, first checking fatter values and then thinner ones.
+                match matching_set
+                    .iter()
+                    .filter(|&&index| candidates[index].weight.to_number() >= query.weight.to_number())
+                    .min_by_key(|&&index| candidates[index].weight.to_number() - query.weight.to_number())
+                {
+                    Some(&matching_index) => candidates[matching_index].weight,
+                    None => {
+                        let matching_index = *matching_set
+                            .iter()
+                            .min_by_key(|&&index| query.weight.to_number() - candidates[index].weight.to_number())
+                            .unwrap();
+                        candidates[matching_index].weight
+                    }
+                }
+            };
+            matching_set.retain(|&index| candidates[index].weight == matching_weight);
+
+            // Step 4d concerns `font-size`, but fonts in `font-kit` are unsized, so we ignore that.
+
+            // Return the result.
+            matching_set.into_iter().next().ok_or(FontLoadingError::NoSuchFontInCollection)
+        }
+    }
 }
diff --git a/crates/zng-ext-font/src/shaping.rs b/crates/zng-ext-font/src/shaping.rs
index 35f96b764..2f996db77 100644
--- a/crates/zng-ext-font/src/shaping.rs
+++ b/crates/zng-ext-font/src/shaping.rs
@@ -36,16 +36,6 @@ impl fmt::Display for GlyphLoadingError {
         }
     }
 }
-#[cfg(not(target_arch = "wasm32"))]
-impl From<font_kit::error::GlyphLoadingError> for GlyphLoadingError {
-    fn from(value: font_kit::error::GlyphLoadingError) -> Self {
-        use GlyphLoadingError::*;
-        match value {
-            font_kit::error::GlyphLoadingError::NoSuchGlyph => NoSuchGlyph,
-            font_kit::error::GlyphLoadingError::PlatformError => PlatformError,
-        }
-    }
-}
 
 /// Extra configuration for [`shape_text`](Font::shape_text).
 #[derive(Debug, Clone)]
diff --git a/crates/zng-task/Cargo.toml b/crates/zng-task/Cargo.toml
index 643c97350..7d47c70ef 100644
--- a/crates/zng-task/Cargo.toml
+++ b/crates/zng-task/Cargo.toml
@@ -18,7 +18,7 @@ deadlock_detection = ["parking_lot/deadlock_detection"]
 
 # Enables ipc tasks.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `cfg(not(any(target_os = "android", target_arch = "wasm32")))` builds.
 ipc = [
     "dep:duct",
     "dep:serde",
@@ -90,5 +90,8 @@ dunce = "1.0"
 duct = { version = "0.13", optional = true }
 fs4 = { version = "0.9", optional = true }
 
+[build-dependencies]
+cfg_aliases = "0.2"
+
 [package.metadata.docs.rs]
 all-features = true
diff --git a/crates/zng-task/README.md b/crates/zng-task/README.md
index ce2a1895c..dd6ff46ca 100644
--- a/crates/zng-task/README.md
+++ b/crates/zng-task/README.md
@@ -13,7 +13,7 @@ Enables parking_lot deadlock detection.
 #### `"ipc"`
 Enables ipc tasks.
 
-Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+Only enables in `cfg(not(any(target_os = "android", target_arch = "wasm32")))` builds.
 
 #### `"http"`
 Enables http tasks.
diff --git a/crates/zng-task/build.rs b/crates/zng-task/build.rs
new file mode 100644
index 000000000..f56b509ad
--- /dev/null
+++ b/crates/zng-task/build.rs
@@ -0,0 +1,9 @@
+#![recursion_limit = "256"]
+
+fn main() {
+    cfg_aliases::cfg_aliases! {
+        wasm: { target_arch = "wasm32" },
+        android: { target_os = "android" },
+        ipc: { all(feature="ipc", not(any(android, wasm))) },
+    }
+}
diff --git a/crates/zng-task/src/ipc.rs b/crates/zng-task/src/ipc.rs
index 056194158..01189757c 100644
--- a/crates/zng-task/src/ipc.rs
+++ b/crates/zng-task/src/ipc.rs
@@ -1,4 +1,4 @@
-#![cfg(all(feature = "ipc", not(target_arch = "wasm32")))]
+#![cfg(ipc)]
 
 //! IPC tasks.
 //!
diff --git a/crates/zng-view-api/Cargo.toml b/crates/zng-view-api/Cargo.toml
index ea1d287f3..bfd5a9ee4 100644
--- a/crates/zng-view-api/Cargo.toml
+++ b/crates/zng-view-api/Cargo.toml
@@ -16,8 +16,7 @@ default = ["ipc"]
 
 # Enables creation of separate or pre-build view.
 #
-# When this is enabled communication with the view is (de)serialized which can add a 
-# minor cost, something like a 1ms per 3MB frame request.
+# Only enables in `cfg(not(any(target_os = "android", target_arch = "wasm32")))` builds.
 ipc = ["dep:ipc-channel"]
 
 # Implement `IntoVar<T>` for API types.
@@ -59,5 +58,8 @@ tracing-shared = { version = "0.1.5", default-features = false, features = ["log
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 web-time = "1.0"
 
+[build-dependencies]
+cfg_aliases = "0.2"
+
 [package.metadata.docs.rs]
 all-features = true
diff --git a/crates/zng-view-api/README.md b/crates/zng-view-api/README.md
index a90778670..13c3c0ed3 100644
--- a/crates/zng-view-api/README.md
+++ b/crates/zng-view-api/README.md
@@ -10,8 +10,7 @@ This crate provides 2 feature flags, 1 enabled by default.
 #### `"ipc"`
 Enables creation of separate or pre-build view.
 
-When this is enabled communication with the view is (de)serialized which can add a
-minor cost, something like a 1ms per 3MB frame request.
+Only enables in `cfg(not(any(target_os = "android", target_arch = "wasm32")))` builds.
 
 *Enabled by default.*
 
diff --git a/crates/zng-view-api/build.rs b/crates/zng-view-api/build.rs
new file mode 100644
index 000000000..f56b509ad
--- /dev/null
+++ b/crates/zng-view-api/build.rs
@@ -0,0 +1,9 @@
+#![recursion_limit = "256"]
+
+fn main() {
+    cfg_aliases::cfg_aliases! {
+        wasm: { target_arch = "wasm32" },
+        android: { target_os = "android" },
+        ipc: { all(feature="ipc", not(any(android, wasm))) },
+    }
+}
diff --git a/crates/zng-view-api/src/app_process.rs b/crates/zng-view-api/src/app_process.rs
index c3c4b8d05..0c3d20416 100644
--- a/crates/zng-view-api/src/app_process.rs
+++ b/crates/zng-view-api/src/app_process.rs
@@ -6,7 +6,7 @@ use std::{
     time::Instant,
 };
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 use std::time::Duration;
 
 use zng_txt::Txt;
@@ -20,6 +20,13 @@ pub(crate) const VIEW_VERSION: &str = "ZNG_VIEW_VERSION";
 pub(crate) const VIEW_SERVER: &str = "ZNG_VIEW_SERVER";
 pub(crate) const VIEW_MODE: &str = "ZNG_VIEW_MODE";
 
+#[derive(Clone, Copy)]
+enum ViewState {
+    Offline,
+    Online,
+    Suspended,
+}
+
 /// View Process controller, used in the App Process.
 ///
 /// # Exit
@@ -29,10 +36,10 @@ pub(crate) const VIEW_MODE: &str = "ZNG_VIEW_MODE";
 ///
 /// [killed]: std::process::Child::kill
 /// [exits]: std::process::exit
-#[cfg_attr(not(feature = "ipc"), allow(unused))]
+#[cfg_attr(not(ipc), allow(unused))]
 pub struct Controller {
     process: Option<std::process::Child>,
-    online: bool,
+    view_state: ViewState,
     generation: ViewProcessGen,
     is_respawn: bool,
     view_process_exe: PathBuf,
@@ -107,7 +114,7 @@ impl Controller {
 
         let mut c = Controller {
             same_process: process.is_none(),
-            online: false,
+            view_state: ViewState::Offline,
             process,
             view_process_exe,
             view_process_env,
@@ -136,7 +143,7 @@ impl Controller {
 
     /// View-process is connected and ready to respond.
     pub fn online(&self) -> bool {
-        self.online
+        matches!(self.view_state, ViewState::Online)
     }
 
     /// View-process generation.
@@ -160,7 +167,7 @@ impl Controller {
     }
 
     fn offline_err(&self) -> Result<(), ViewProcessOffline> {
-        if self.online {
+        if self.online() {
             Ok(())
         } else {
             Err(ViewProcessOffline)
@@ -226,13 +233,13 @@ impl Controller {
             });
             None
         } else {
-            #[cfg(not(feature = "ipc"))]
+            #[cfg(not(ipc))]
             {
                 let _ = (view_process_exe, view_process_env);
                 panic!("expected only same_process mode with `ipc` feature disabled");
             }
 
-            #[cfg(feature = "ipc")]
+            #[cfg(ipc)]
             {
                 let mut process = std::process::Command::new(view_process_exe);
                 for (name, val) in view_process_env {
@@ -251,7 +258,7 @@ impl Controller {
         let (req, rsp, ev) = match init.connect() {
             Ok(r) => r,
             Err(e) => {
-                #[cfg(feature = "ipc")]
+                #[cfg(ipc)]
                 if let Some(mut p) = process {
                     if let Err(ke) = p.kill() {
                         tracing::error!(
@@ -289,13 +296,30 @@ impl Controller {
 
     /// Handle an [`Event::Inited`].
     ///
-    /// Set the online flag.
+    /// Set the online flag to `true`.
     pub fn handle_inited(&mut self, gen: ViewProcessGen) {
-        if gen == self.generation {
-            self.online = true;
+        match self.view_state {
+            ViewState::Offline => {
+                if self.generation == gen {
+                    // crash respawn already sets gen
+                    self.view_state = ViewState::Online;
+                }
+            }
+            ViewState::Suspended => {
+                self.generation = gen;
+                self.view_state = ViewState::Online;
+            }
+            ViewState::Online => {}
         }
     }
 
+    /// Handle an [`Event::Suspended`].
+    ///
+    /// Set the online flat to `false`.
+    pub fn handle_suspended(&mut self) {
+        self.view_state = ViewState::Suspended;
+    }
+
     /// Handle an [`Event::Disconnected`].
     ///
     /// The `gen` parameter is the generation provided by the event. It is used to determinate if the disconnect has
@@ -317,12 +341,12 @@ impl Controller {
     /// If another disconnect happens during the view-process startup dialog.
     pub fn handle_disconnect(&mut self, gen: ViewProcessGen) {
         if gen == self.generation {
-            #[cfg(not(feature = "ipc"))]
+            #[cfg(not(ipc))]
             {
                 tracing::error!(target: "vp_respawn", "cannot recover in same_process mode (no ipc)");
             }
 
-            #[cfg(feature = "ipc")]
+            #[cfg(ipc)]
             {
                 self.respawn_impl(true)
             }
@@ -336,19 +360,19 @@ impl Controller {
     ///
     /// [`handle_disconnect`]: Controller::handle_disconnect
     pub fn respawn(&mut self) {
-        #[cfg(not(feature = "ipc"))]
+        #[cfg(not(ipc))]
         {
             tracing::error!(target: "vp_respawn", "cannot recover in same_process mode (no ipc)");
         }
 
-        #[cfg(feature = "ipc")]
+        #[cfg(ipc)]
         self.respawn_impl(false);
     }
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     fn respawn_impl(&mut self, is_crash: bool) {
         use zng_unit::TimeUnits;
 
-        self.online = false;
+        self.view_state = ViewState::Offline;
         self.is_respawn = true;
 
         let mut process = if let Some(p) = self.process.take() {
@@ -502,7 +526,7 @@ impl Drop for Controller {
     /// Kills the View Process, unless it is running in the same process.
     fn drop(&mut self) {
         let _ = self.exit();
-        #[cfg(feature = "ipc")]
+        #[cfg(ipc)]
         if let Some(mut process) = self.process.take() {
             if process.try_wait().is_err() {
                 std::thread::sleep(Duration::from_secs(1));
diff --git a/crates/zng-view-api/src/ipc.rs b/crates/zng-view-api/src/ipc.rs
index ce11eacc1..e65df7155 100644
--- a/crates/zng-view-api/src/ipc.rs
+++ b/crates/zng-view-api/src/ipc.rs
@@ -4,10 +4,10 @@ use std::{fmt, ops::Deref, time::Duration};
 
 use crate::{AnyResult, Event, Request, Response};
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 use ipc_channel::ipc::{channel, IpcOneShotServer, IpcReceiver, IpcSender};
 
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 use flume::unbounded as channel;
 
 use parking_lot::Mutex;
@@ -19,22 +19,22 @@ pub(crate) type IpcResult<T> = std::result::Result<T, Disconnected>;
 /// Bytes sender.
 ///
 /// Use [`bytes_channel`] to create.
-#[cfg_attr(feature = "ipc", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(ipc, derive(serde::Serialize, serde::Deserialize))]
 pub struct IpcBytesSender {
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     sender: ipc_channel::ipc::IpcBytesSender,
-    #[cfg(not(feature = "ipc"))]
+    #[cfg(not(ipc))]
     sender: flume::Sender<Vec<u8>>,
 }
 impl IpcBytesSender {
     /// Send a byte package.
     pub fn send(&self, bytes: Vec<u8>) -> IpcResult<()> {
-        #[cfg(feature = "ipc")]
+        #[cfg(ipc)]
         {
             self.sender.send(&bytes).map_err(handle_io_error)
         }
 
-        #[cfg(not(feature = "ipc"))]
+        #[cfg(not(ipc))]
         self.sender.send(bytes).map_err(handle_send_error)
     }
 }
@@ -47,11 +47,11 @@ impl fmt::Debug for IpcBytesSender {
 /// Bytes receiver.
 ///
 /// Use [`bytes_channel`] to create.
-#[cfg_attr(feature = "ipc", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(ipc, derive(serde::Serialize, serde::Deserialize))]
 pub struct IpcBytesReceiver {
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     recv: ipc_channel::ipc::IpcBytesReceiver,
-    #[cfg(not(feature = "ipc"))]
+    #[cfg(not(ipc))]
     recv: flume::Receiver<Vec<u8>>,
 }
 impl IpcBytesReceiver {
@@ -67,20 +67,20 @@ impl fmt::Debug for IpcBytesReceiver {
 }
 
 /// Create a bytes channel.
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 pub fn bytes_channel() -> (IpcBytesSender, IpcBytesReceiver) {
     let (sender, recv) = ipc_channel::ipc::bytes_channel().unwrap();
     (IpcBytesSender { sender }, IpcBytesReceiver { recv })
 }
 
 /// Create a bytes channel.
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 pub fn bytes_channel() -> (IpcBytesSender, IpcBytesReceiver) {
     let (sender, recv) = flume::unbounded();
     (IpcBytesSender { sender }, IpcBytesReceiver { recv })
 }
 
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 mod arc_bytes {
     pub fn serialize<S>(bytes: &std::sync::Arc<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
     where
@@ -104,21 +104,21 @@ mod arc_bytes {
 #[derive(Clone, Serialize, Deserialize)]
 pub struct IpcBytes {
     // `IpcSharedMemory` cannot have zero length, we use `None` in this case.
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     bytes: Option<ipc_channel::ipc::IpcSharedMemory>,
     // `IpcSharedMemory` only clones a pointer.
-    #[cfg(not(feature = "ipc"))]
+    #[cfg(not(ipc))]
     #[serde(with = "arc_bytes")]
     bytes: std::sync::Arc<Vec<u8>>,
 }
 /// Pointer equal.
 impl PartialEq for IpcBytes {
-    #[cfg(not(feature = "ipc"))]
+    #[cfg(not(ipc))]
     fn eq(&self, other: &Self) -> bool {
         std::sync::Arc::ptr_eq(&self.bytes, &other.bytes)
     }
 
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     fn eq(&self, other: &Self) -> bool {
         match (&self.bytes, &other.bytes) {
             (None, None) => true,
@@ -131,7 +131,7 @@ impl IpcBytes {
     /// Copy the `bytes` to a new shared memory allocation.
     pub fn from_slice(bytes: &[u8]) -> Self {
         IpcBytes {
-            #[cfg(feature = "ipc")]
+            #[cfg(ipc)]
             bytes: {
                 if bytes.is_empty() {
                     None
@@ -139,7 +139,7 @@ impl IpcBytes {
                     Some(ipc_channel::ipc::IpcSharedMemory::from_bytes(bytes))
                 }
             },
-            #[cfg(not(feature = "ipc"))]
+            #[cfg(not(ipc))]
             bytes: std::sync::Arc::new(bytes.to_vec()),
         }
     }
@@ -147,12 +147,12 @@ impl IpcBytes {
     /// If the `"ipc"` feature is enabled copy the bytes to a new shared memory region, if not
     /// just wraps the `bytes` in a shared pointer.
     pub fn from_vec(bytes: Vec<u8>) -> Self {
-        #[cfg(feature = "ipc")]
+        #[cfg(ipc)]
         {
             Self::from_slice(&bytes)
         }
 
-        #[cfg(not(feature = "ipc"))]
+        #[cfg(not(ipc))]
         IpcBytes {
             bytes: std::sync::Arc::new(bytes),
         }
@@ -162,11 +162,11 @@ impl IpcBytes {
     ///
     /// If the `"ipc"` feature is not enabled and `self` is the only reference this operation is zero-cost.
     pub fn to_vec(self) -> Vec<u8> {
-        #[cfg(feature = "ipc")]
+        #[cfg(ipc)]
         {
             self.bytes.map(|s| s.to_vec()).unwrap_or_default()
         }
-        #[cfg(not(feature = "ipc"))]
+        #[cfg(not(ipc))]
         {
             match std::sync::Arc::try_unwrap(self.bytes) {
                 Ok(d) => d,
@@ -176,13 +176,13 @@ impl IpcBytes {
     }
 
     /// Returns the underlying shared memory reference, if the bytes are not zero-length.
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     pub fn ipc_shared_memory(&self) -> Option<ipc_channel::ipc::IpcSharedMemory> {
         self.bytes.clone()
     }
 
     /// Returns the underlying shared reference.
-    #[cfg(not(feature = "ipc"))]
+    #[cfg(not(ipc))]
     pub fn arc(&self) -> std::sync::Arc<Vec<u8>> {
         self.bytes.clone()
     }
@@ -191,10 +191,10 @@ impl Deref for IpcBytes {
     type Target = [u8];
 
     fn deref(&self) -> &Self::Target {
-        #[cfg(feature = "ipc")]
+        #[cfg(ipc)]
         return if let Some(bytes) = &self.bytes { bytes } else { &[] };
 
-        #[cfg(not(feature = "ipc"))]
+        #[cfg(not(ipc))]
         &self.bytes
     }
 }
@@ -204,9 +204,9 @@ impl fmt::Debug for IpcBytes {
     }
 }
 
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 type IpcSender<T> = flume::Sender<T>;
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 type IpcReceiver<T> = flume::Receiver<T>;
 
 /// Channel disconnected error.
@@ -220,12 +220,12 @@ impl fmt::Display for Disconnected {
 impl std::error::Error for Disconnected {}
 
 /// Call `new`, then spawn the view-process using the `name` then call `connect`.
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 pub(crate) struct AppInit {
     server: IpcOneShotServer<AppInitMsg>,
     name: Txt,
 }
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 impl AppInit {
     pub fn new() -> Self {
         let (server, name) = IpcOneShotServer::new().expect("failed to create init channel");
@@ -266,7 +266,7 @@ impl AppInit {
 }
 
 /// Start the view-process server and waits for `(request, response, event)`.
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 pub fn connect_view_process(server_name: Txt) -> IpcResult<ViewChannels> {
     let _s = tracing::trace_span!("connect_view_process").entered();
 
@@ -295,7 +295,7 @@ pub fn connect_view_process(server_name: Txt) -> IpcResult<ViewChannels> {
 /// )
 type AppInitMsg = (IpcSender<Request>, IpcSender<(IpcSender<Response>, IpcSender<Event>)>);
 
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 pub(crate) struct AppInit {
     // (
     //    RequestSender,
@@ -306,7 +306,7 @@ pub(crate) struct AppInit {
     init: flume::Receiver<AppInitMsg>,
     name: Txt,
 }
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 mod name_map {
     use std::{
         collections::HashMap,
@@ -333,7 +333,7 @@ mod name_map {
         }
     }
 }
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 impl AppInit {
     pub fn new() -> Self {
         use std::sync::atomic::{AtomicU32, Ordering};
@@ -372,7 +372,7 @@ impl AppInit {
 }
 
 /// Start the view-process server and waits for `(request, response, event)`.
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 pub fn connect_view_process(server_name: Txt) -> IpcResult<ViewChannels> {
     let app_init_sender = name_map::get().lock().unwrap().remove(&server_name).unwrap();
 
@@ -473,7 +473,7 @@ impl EventReceiver {
     }
 }
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 fn handle_recv_error(e: ipc_channel::ipc::IpcError) -> Disconnected {
     match e {
         ipc_channel::ipc::IpcError::Disconnected => Disconnected,
@@ -483,14 +483,14 @@ fn handle_recv_error(e: ipc_channel::ipc::IpcError) -> Disconnected {
         }
     }
 }
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 fn handle_recv_error(e: flume::RecvError) -> Disconnected {
     match e {
         flume::RecvError::Disconnected => Disconnected,
     }
 }
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 #[allow(clippy::boxed_local)]
 fn handle_send_error(e: ipc_channel::Error) -> Disconnected {
     match *e {
@@ -514,12 +514,12 @@ fn handle_send_error(e: ipc_channel::Error) -> Disconnected {
     }
 }
 
-#[cfg(not(feature = "ipc"))]
+#[cfg(not(ipc))]
 fn handle_send_error<T>(_: flume::SendError<T>) -> Disconnected {
     Disconnected
 }
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 fn handle_io_error(e: std::io::Error) -> Disconnected {
     match e.kind() {
         std::io::ErrorKind::BrokenPipe => Disconnected,
@@ -527,7 +527,7 @@ fn handle_io_error(e: std::io::Error) -> Disconnected {
     }
 }
 
-#[cfg(all(test, feature = "ipc"))]
+#[cfg(all(test, ipc))]
 mod tests {
     use std::thread;
 
diff --git a/crates/zng-view-api/src/lib.rs b/crates/zng-view-api/src/lib.rs
index 5f02b1212..adf6c04af 100644
--- a/crates/zng-view-api/src/lib.rs
+++ b/crates/zng-view-api/src/lib.rs
@@ -22,7 +22,7 @@
 #![warn(missing_docs)]
 #![warn(unused_extern_crates)]
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 use serde::{Deserialize, Serialize};
 
 /// The *App Process* and *View Process* must be build using the same exact version and this is
@@ -66,7 +66,7 @@ use zng_unit::{DipPoint, DipRect, DipSize, Factor, Px, PxRect};
 
 /// Packaged API request.
 #[derive(Debug)]
-#[cfg_attr(feature = "ipc", derive(Serialize, Deserialize))]
+#[cfg_attr(ipc, derive(Serialize, Deserialize))]
 pub struct Request(RequestData);
 impl Request {
     /// Returns `true` if the request can only be made after the *init* event.
@@ -101,7 +101,7 @@ impl Request {
 
 /// Packaged API response.
 #[derive(Debug)]
-#[cfg_attr(feature = "ipc", derive(Serialize, Deserialize))]
+#[cfg_attr(ipc, derive(Serialize, Deserialize))]
 pub struct Response(ResponseData);
 impl Response {
     /// If this response must be send back to the app process. Only [`Api`] methods
@@ -142,7 +142,7 @@ macro_rules! declare_api {
             ) $(-> $ResponseType:ty)?;
         )*
     ) => {
-        #[cfg_attr(feature="ipc", derive(Serialize, Deserialize))]
+        #[cfg_attr(ipc, derive(Serialize, Deserialize))]
         #[allow(non_camel_case_types)]
         #[allow(clippy::large_enum_variant)]
         #[repr(u32)]
@@ -191,7 +191,7 @@ macro_rules! declare_api {
         }
 
         #[derive(Debug)]
-        #[cfg_attr(feature="ipc", derive(Serialize, Deserialize))]
+        #[cfg_attr(ipc, derive(Serialize, Deserialize))]
         #[allow(non_camel_case_types)]
         #[repr(u32)]
         enum ResponseData {
diff --git a/crates/zng-view-api/src/types.rs b/crates/zng-view-api/src/types.rs
index 9f6345dcb..f9cd9defa 100644
--- a/crates/zng-view-api/src/types.rs
+++ b/crates/zng-view-api/src/types.rs
@@ -160,6 +160,8 @@ pub enum Ime {
 pub enum Event {
     /// View-process inited.
     Inited(Inited),
+    /// View-process suspended.
+    Suspended,
 
     /// The event channel disconnected, probably because the view-process crashed.
     ///
diff --git a/crates/zng-view/Cargo.toml b/crates/zng-view/Cargo.toml
index 821cb3930..f88b81370 100644
--- a/crates/zng-view/Cargo.toml
+++ b/crates/zng-view/Cargo.toml
@@ -18,6 +18,8 @@ default = ["ipc", "software"]
 #
 # If this is enabled all communication with the view is serialized/deserialized, 
 # even in same-process mode.
+#
+# Only enables in `cfg(not(target_os = "android"))` builds.
 ipc = ["zng-view-api/ipc"]
 
 # Enables software renderer fallback.
@@ -39,6 +41,16 @@ bundle_licenses = ["zng-tp-licenses/bundle"]
 # Removed for now because there is no way to exclude this feature from test runs that use --all-features
 # avif = ["image/avif", "image/avif-native"]
 
+# Standard Android backend that requires a build system that can compile Java or Kotlin and fetch Android dependencies.
+#
+# See `https://docs.rs/winit/latest/winit/platform/android/` for more details.
+android_game_activity = ["winit/android-game-activity"]
+
+# Basic Android backend that does not require Java.
+#
+# See `https://docs.rs/winit/latest/winit/platform/android/` for more details. 
+android_native_activity = ["winit/android-native-activity"]
+
 [lib]
 crate-type = ["lib", "cdylib"]
 
@@ -91,9 +103,11 @@ serde = "1.0"
 accesskit = "0.16"
 accesskit_winit = "0.22"
 # rfd
-[target.'cfg(not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd")))'.dependencies.rfd]
+## cfg(not(linux, android))
+[target.'cfg(not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", target_os = "android")))'.dependencies.rfd]
 version = "0.14"
 default-features = false
+## cfg(linux)
 [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies.rfd]
 version = "0.14"
 default-features = false
@@ -123,8 +137,9 @@ features = ["Foundation_Collections", "System_UserProfile", "UI_ViewManagement"]
 [target.'cfg(windows)'.dependencies]
 clipboard-win = { version = "5.0", features = ["std"] }
 
-[target.'cfg(not(windows))'.dependencies]
+[target.'cfg(not(any(windows, target_os = "android")))'.dependencies]
 arboard = "3.3"
+[target.'cfg(not(windows))'.dependencies]
 sys-locale = "0.3"
 
 [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
@@ -144,6 +159,7 @@ features = ["NSString"]
 zng-tp-licenses = { path = "../zng-tp-licenses", version = "0.2.7", features = [
     "build",
 ] }
+cfg_aliases = "0.2"
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/crates/zng-view/README.md b/crates/zng-view/README.md
index 882b391b2..a6cab5c3f 100644
--- a/crates/zng-view/README.md
+++ b/crates/zng-view/README.md
@@ -5,7 +5,7 @@ This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-fi
 <!--do doc --readme features-->
 ## Cargo Features
 
-This crate provides 3 feature flags, 2 enabled by default.
+This crate provides 5 feature flags, 2 enabled by default.
 
 #### `"ipc"`
 Enables pre-build and init as view-process.
@@ -13,6 +13,8 @@ Enables pre-build and init as view-process.
 If this is enabled all communication with the view is serialized/deserialized,
 even in same-process mode.
 
+Only enables in `cfg(not(target_os = "android"))` builds.
+
 *Enabled by default.*
 
 #### `"software"`
@@ -29,6 +31,16 @@ Needs `cargo-about` and Internet connection during build.
 
 Not enabled by default. Note that `"view_prebuilt"` always bundles licenses.
 
+#### `"android_game_activity"`
+Standard Android backend that requires a build system that can compile Java or Kotlin and fetch Android dependencies.
+
+See `https://docs.rs/winit/latest/winit/platform/android/` for more details.
+
+#### `"android_native_activity"`
+Basic Android backend that does not require Java.
+
+See `https://docs.rs/winit/latest/winit/platform/android/` for more details.
+
 <!--do doc --readme #SECTION-END-->
 
 
diff --git a/crates/zng-view/build.rs b/crates/zng-view/build.rs
index 525f03562..8883c1853 100644
--- a/crates/zng-view/build.rs
+++ b/crates/zng-view/build.rs
@@ -1,6 +1,11 @@
 // #![allow(unexpected_cfgs)]
 
 fn main() {
+    cfg_aliases::cfg_aliases! {
+        android: { target_os = "android" },
+        ipc: { all(feature = "ipc", not(android)) },
+    }
+
     tp_licenses();
 }
 
diff --git a/crates/zng-view/src/extensions.rs b/crates/zng-view/src/extensions.rs
index 30c570de8..d221b3ba8 100644
--- a/crates/zng-view/src/extensions.rs
+++ b/crates/zng-view/src/extensions.rs
@@ -53,6 +53,28 @@ pub trait ViewExtension: Send + Any {
 
     /// System warning low memory, release unused memory, caches.
     fn low_memory(&mut self) {}
+
+    /// App is being suspended, all graphic resources must be dropped.
+    ///
+    /// Android and iOS apps can be suspended without fully exiting, all graphic resources must be dropped on suspension, and
+    /// any persistent state must be flushed because the app will process will exit if the user does not return to the app.
+    ///
+    /// Note that [`window`] and [`renderer`] resources are managed by the app and will be dropped automatically, this
+    /// callback only needs to drop custom graphic resources.
+    ///
+    /// [`window`]: Self::window
+    /// [`renderer`]: Self::renderer
+    fn suspended(&mut self) {}
+
+    /// App resumed from a suspended state.
+    ///
+    /// Expect [`window`] and [`renderer`] requests to recrate previous instances.
+    ///
+    /// Note that this is not called on init, only after a suspension.
+    ///
+    /// [`window`]: Self::window
+    /// [`renderer`]: Self::renderer
+    fn resumed(&mut self) {}
 }
 
 /// Represents a view extension associated with a headed window instance.
@@ -828,6 +850,18 @@ impl ViewExtensions {
         }
     }
 
+    pub(crate) fn suspended(&mut self) {
+        for ext in self.exts.iter_mut() {
+            ext.suspended();
+        }
+    }
+
+    pub(crate) fn resumed(&mut self) {
+        for ext in self.exts.iter_mut() {
+            ext.resumed();
+        }
+    }
+
     /// Add `other` to self.
     pub fn append(&mut self, mut other: ViewExtensions) {
         self.exts.append(&mut other.exts);
diff --git a/crates/zng-view/src/gl.rs b/crates/zng-view/src/gl.rs
index 7d55108ca..bc87990c1 100644
--- a/crates/zng-view/src/gl.rs
+++ b/crates/zng-view/src/gl.rs
@@ -196,10 +196,13 @@ impl GlContextManager {
             target_os = "dragonfly",
             target_os = "freebsd",
             target_os = "openbsd",
-            target_os = "netbsd"
+            target_os = "netbsd",
         ))]
         let display_pref = DisplayApiPreference::GlxThenEgl(Box::new(winit::platform::x11::register_xlib_error_hook));
 
+        #[cfg(target_os = "android")]
+        let display_pref = DisplayApiPreference::Egl;
+
         #[cfg(target_os = "macos")]
         let display_pref = DisplayApiPreference::Cgl;
 
@@ -213,7 +216,7 @@ impl GlContextManager {
 
         let mut template = ConfigTemplateBuilder::new()
             .with_alpha_size(8)
-            .with_transparency(true)
+            .with_transparency(cfg!(not(target_os = "android")))
             .with_surface_type(ConfigSurfaceTypes::WINDOW)
             .prefer_hardware_accelerated(hardware);
         if let GlWindowCreation::Before(w) = &window {
@@ -386,6 +389,9 @@ impl GlContextManager {
         ))]
         let display_pref = DisplayApiPreference::GlxThenEgl(Box::new(winit::platform::x11::register_xlib_error_hook));
 
+        #[cfg(target_os = "android")]
+        let display_pref = DisplayApiPreference::Egl;
+
         #[cfg(target_os = "macos")]
         let display_pref = DisplayApiPreference::Cgl;
 
@@ -676,23 +682,26 @@ pub(crate) fn warmup() {}
 
 // check if equal or newer then 3.1
 fn check_wr_gl_version(gl: &dyn gl::Gl) -> Result<(), String> {
-    let version = gl.get_string(gl::VERSION);
-
-    // versions can be "3.1" or "3.1.0" or "3.3 some text"
-    // we take the major and optionally minor versions.
-    let ver: Vec<_> = version.split(['.', ' ']).take(2).filter_map(|n| n.parse::<u8>().ok()).collect();
-
-    let supported = if ver[0] == 3 {
-        ver.get(1).copied().unwrap_or(0) >= 1
-    } else {
-        ver[0] > 3
+    let mut version = [0; 2];
+    // SAFETY: API available in all impls
+    unsafe {
+        gl.get_integer_v(gl::MAJOR_VERSION, &mut version[..1]);
+        gl.get_integer_v(gl::MAJOR_VERSION, &mut version[1..]);
     };
 
-    if supported {
-        Ok(())
-    } else {
-        Err(format!("webrender requires OpenGL 3.1 or newer, found {version}"))
+    if version[0] >= 3 {
+        let min_minor = match gl.get_type() {
+            gl::GlType::Gl => 1,
+            gl::GlType::Gles => 0,
+        };
+        if version[1] >= min_minor {
+            return Ok(());
+        }
     }
+    Err(format!(
+        "webrender requires OpenGL >=3.1 or OpenGL ES >=3.0, found {}",
+        gl.get_string(gl::VERSION)
+    ))
 }
 
 /// Glutin, SWGL config to attempt.
diff --git a/crates/zng-view/src/image_cache.rs b/crates/zng-view/src/image_cache.rs
index e5678e7d0..60a39ea4a 100644
--- a/crates/zng-view/src/image_cache.rs
+++ b/crates/zng-view/src/image_cache.rs
@@ -847,6 +847,10 @@ impl ImageCache {
     pub(crate) fn on_low_memory(&mut self) {
         // app-process controls this cache
     }
+
+    pub(crate) fn clear(&mut self) {
+        self.images.clear();
+    }
 }
 
 /// (pixels, size, ppi, is_opaque, is_mask)
@@ -1336,12 +1340,16 @@ mod capture {
             scale_factor: Factor,
             mask: Option<ImageMaskMode>,
         ) -> ImageLoadedData {
+            let format = match gl.get_type() {
+                gleam::gl::GlType::Gl => gleam::gl::BGRA,
+                gleam::gl::GlType::Gles => gleam::gl::RGBA,
+            };
             let pixels_flipped = gl.read_pixels(
                 rect.origin.x.0,
                 rect.origin.y.0,
                 rect.size.width.0,
                 rect.size.height.0,
-                gleam::gl::BGRA,
+                format,
                 gleam::gl::UNSIGNED_BYTE,
             );
             let mut buf = vec![0u8; pixels_flipped.len()];
@@ -1352,8 +1360,10 @@ mod capture {
             }
 
             if let Some(mask) = mask {
-                for bgra in buf.chunks_exact_mut(4) {
-                    bgra.swap(0, 3);
+                if format == gleam::gl::BGRA {
+                    for bgra in buf.chunks_exact_mut(4) {
+                        bgra.swap(0, 3);
+                    }
                 }
                 let (pixels, size, ppi, is_opaque, is_mask) = Self::convert_decoded(
                     image::DynamicImage::ImageRgba8(
@@ -1379,6 +1389,12 @@ mod capture {
                     pixels,
                 }
             } else {
+                if format == gleam::gl::RGBA {
+                    for rgba in buf.chunks_exact_mut(4) {
+                        rgba.swap(0, 3);
+                    }
+                }
+
                 let is_opaque = buf.chunks_exact(4).all(|bgra| bgra[3] == 255);
 
                 let data = IpcBytes::from_vec(buf);
diff --git a/crates/zng-view/src/lib.rs b/crates/zng-view/src/lib.rs
index 500f810ee..180df1329 100644
--- a/crates/zng-view/src/lib.rs
+++ b/crates/zng-view/src/lib.rs
@@ -105,9 +105,14 @@ use winit::{
     event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy},
     keyboard::ModifiersState,
     monitor::MonitorHandle,
-    platform::modifier_supplement::KeyEventExtModifierSupplement,
 };
 
+#[cfg(not(target_os = "android"))]
+use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
+
+#[cfg(target_os = "android")]
+use winit::platform::android::EventLoopBuilderExtAndroid;
+
 mod config;
 mod display_list;
 mod gl;
@@ -120,6 +125,8 @@ use surface::*;
 
 pub mod extensions;
 
+pub mod platform;
+
 /// Webrender build used in the view-process.
 #[doc(no_inline)]
 pub use webrender;
@@ -151,7 +158,7 @@ use zng_view_api::{
 
 use rustc_hash::FxHashMap;
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 zng_env::on_process_start!(|_| {
     if std::env::var("ZNG_VIEW_NO_INIT_START").is_err() {
         view_process_main();
@@ -165,7 +172,7 @@ zng_env::on_process_start!(|_| {
 ///
 /// You can also disable start on init by setting the `ZNG_VIEW_NO_INIT_START` environment variable. In this
 /// case you must manually call this function.
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 pub fn view_process_main() {
     let config = match ViewConfig::from_env() {
         Some(c) => c,
@@ -190,7 +197,7 @@ pub fn view_process_main() {
     zng_env::exit(0)
 }
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 #[doc(hidden)]
 #[no_mangle]
 pub extern "C" fn extern_view_process_main() {
@@ -214,12 +221,29 @@ pub extern "C" fn extern_view_process_main() {
 /// the panics to the main thread, this causes the app to stop responding while still receiving
 /// event signals, causing the operating system to not detect that the app is frozen. It is recommended
 /// that you build with `panic=abort` or use [`std::panic::set_hook`] to detect these background panics.
-pub fn run_same_process(run_app: impl FnOnce() + Send + 'static) {
-    run_same_process_extended(run_app, ViewExtensions::new)
+///
+/// # Android
+///
+/// In Android builds this function takes an `AndroidApp` instance as the first argument, the instance is provided
+/// by the Android entry point, `fn android_main`.
+pub fn run_same_process(
+    #[cfg(target_os = "android")] android_app: platform::android::activity::AndroidApp,
+    run_app: impl FnOnce() + Send + 'static,
+) {
+    run_same_process_extended(
+        #[cfg(target_os = "android")]
+        android_app,
+        run_app,
+        ViewExtensions::new,
+    )
 }
 
 /// Like [`run_same_process`] but with custom API extensions.
-pub fn run_same_process_extended(run_app: impl FnOnce() + Send + 'static, ext: fn() -> ViewExtensions) {
+pub fn run_same_process_extended(
+    #[cfg(target_os = "android")] android_app: platform::android::activity::AndroidApp,
+    run_app: impl FnOnce() + Send + 'static,
+    ext: fn() -> ViewExtensions,
+) {
     let app_thread = thread::Builder::new()
         .name("app".to_owned())
         .spawn(move || {
@@ -246,9 +270,19 @@ pub fn run_same_process_extended(run_app: impl FnOnce() + Send + 'static, ext: f
     let c = ipc::connect_view_process(config.server_name).expect("failed to connect to app in same process");
 
     if config.headless {
-        App::run_headless(c, ext());
+        App::run_headless(
+            #[cfg(target_os = "android")]
+            android_app,
+            c,
+            ext(),
+        );
     } else {
-        App::run_headed(c, ext());
+        App::run_headed(
+            #[cfg(target_os = "android")]
+            android_app,
+            c,
+            ext(),
+        );
     }
 
     if let Err(p) = app_thread.join() {
@@ -256,7 +290,7 @@ pub fn run_same_process_extended(run_app: impl FnOnce() + Send + 'static, ext: f
     }
 }
 
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 #[doc(hidden)]
 #[no_mangle]
 pub extern "C" fn extern_run_same_process(patch: &StaticPatch, run_app: extern "C" fn()) {
@@ -271,17 +305,17 @@ pub extern "C" fn extern_run_same_process(patch: &StaticPatch, run_app: extern "
     #[allow(clippy::redundant_closure)]
     run_same_process(move || run_app())
 }
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 #[allow(deprecated)] // std::panic::PanicInfo is deprecated on nightly (>=1.81)
 fn init_abort(info: &std::panic::PanicInfo) {
     panic_hook(info, "note: aborting to respawn");
 }
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 #[allow(deprecated)] // std::panic::PanicInfo is deprecated on nightly (>=1.81)
 fn ffi_abort(info: &std::panic::PanicInfo) {
     panic_hook(info, "note: aborting to avoid unwind across FFI");
 }
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 #[allow(deprecated)] // std::panic::PanicInfo is deprecated on nightly (>=1.81)
 fn panic_hook(info: &std::panic::PanicInfo, details: &str) {
     // see `default_hook` in https://doc.rust-lang.org/src/std/panicking.rs.html#182
@@ -298,8 +332,6 @@ fn panic_hook(info: &std::panic::PanicInfo, details: &str) {
 
 /// The backend implementation.
 pub(crate) struct App {
-    started: bool,
-
     headless: bool,
 
     exts: ViewExtensions,
@@ -314,7 +346,7 @@ pub(crate) struct App {
     event_sender: ipc::EventSender,
     image_cache: ImageCache,
 
-    gen: ViewProcessGen,
+    generation: ViewProcessGen,
     device_events: bool,
 
     windows: Vec<Window>,
@@ -345,18 +377,19 @@ pub(crate) struct App {
     pending_modifiers_update: Option<ModifiersState>,
     pending_modifiers_focus_clear: bool,
 
-    #[cfg(not(windows))]
+    #[cfg(not(any(windows, target_os = "android")))]
     arboard: Option<arboard::Clipboard>,
 
     config_listener_exit: Option<Box<dyn FnOnce()>>,
 
+    app_state: AppState,
     exited: bool,
 }
 impl fmt::Debug for App {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_struct("HeadlessBackend")
-            .field("started", &self.started)
-            .field("gen", &self.gen)
+            .field("app_state", &self.app_state)
+            .field("generation", &self.generation)
             .field("device_events", &self.device_events)
             .field("windows", &self.windows)
             .field("surfaces", &self.surfaces)
@@ -364,7 +397,33 @@ impl fmt::Debug for App {
     }
 }
 impl winit::application::ApplicationHandler<AppEvent> for App {
-    fn resumed(&mut self, _: &ActiveEventLoop) {}
+    fn resumed(&mut self, winit_loop: &ActiveEventLoop) {
+        if let AppState::Suspended = self.app_state {
+            let mut winit_loop_guard = self.winit_loop.set(winit_loop);
+
+            self.exts.resumed();
+            self.generation = self.generation.next();
+            let available_monitors = self.available_monitors();
+            self.notify(Event::Inited(Inited {
+                generation: self.generation,
+                is_respawn: true,
+                available_monitors,
+                multi_click_config: config::multi_click_config(),
+                key_repeat_config: config::key_repeat_config(),
+                touch_config: config::touch_config(),
+                font_aa: config::font_aa(),
+                animations_config: config::animations_config(),
+                locale_config: config::locale_config(),
+                colors_config: config::colors_config(),
+                extensions: self.exts.api_extensions(),
+            }));
+
+            winit_loop_guard.unset(&mut self.winit_loop);
+        } else {
+            self.exts.init(&self.app_sender);
+        }
+        self.app_state = AppState::Resumed;
+    }
 
     fn window_event(&mut self, winit_loop: &ActiveEventLoop, window_id: winit::window::WindowId, event: WindowEvent) {
         let i = if let Some((i, _)) = self.windows.iter_mut().enumerate().find(|(_, w)| w.window_id() == window_id) {
@@ -605,8 +664,11 @@ impl winit::application::ApplicationHandler<AppEvent> for App {
                     }
 
                     let state = util::element_state_to_key_state(event.state);
+                    #[cfg(not(target_os = "android"))]
                     let key = util::winit_key_to_key(event.key_without_modifiers());
                     let key_modified = util::winit_key_to_key(event.logical_key);
+                    #[cfg(target_os = "android")]
+                    let key = key_modified.clone();
                     let key_code = util::winit_physical_key_to_key_code(event.physical_key);
                     let key_location = util::winit_key_location_to_zng(event.location);
                     let d_id = self.device_id(device_id);
@@ -930,7 +992,12 @@ impl winit::application::ApplicationHandler<AppEvent> for App {
     }
 
     fn suspended(&mut self, _: &ActiveEventLoop) {
-        unimplemented!()
+        self.app_state = AppState::Suspended;
+        self.windows.clear();
+        self.surfaces.clear();
+        self.image_cache.clear();
+        self.exts.suspended();
+        self.notify(Event::Suspended)
     }
 
     fn exiting(&mut self, event_loop: &ActiveEventLoop) {
@@ -956,6 +1023,13 @@ impl winit::application::ApplicationHandler<AppEvent> for App {
         winit_loop_guard.unset(&mut self.winit_loop);
     }
 }
+#[derive(Debug, Clone, Copy)]
+enum AppState {
+    PreInitSuspended,
+    Resumed,
+    Suspended,
+}
+
 struct IdleTrace(Option<tracing::span::EnteredSpan>);
 impl IdleTrace {
     pub fn enter(&mut self) {
@@ -980,7 +1054,11 @@ impl App {
         }
     }
 
-    pub fn run_headless(ipc: ipc::ViewChannels, ext: ViewExtensions) {
+    pub fn run_headless(
+        #[cfg(target_os = "android")] android_app: platform::android::activity::AndroidApp,
+        ipc: ipc::ViewChannels,
+        ext: ViewExtensions,
+    ) {
         tracing::info!("running headless view-process");
 
         gl::warmup();
@@ -997,7 +1075,10 @@ impl App {
         app.headless = true;
 
         let winit_span = tracing::trace_span!("winit::EventLoop::new").entered();
-        let event_loop = EventLoop::new().unwrap();
+        #[cfg(not(target_os = "android"))]
+        let event_loop = EventLoop::builder().build().unwrap();
+        #[cfg(target_os = "android")]
+        let event_loop = EventLoop::builder().with_android_app(android_app).build().unwrap();
         drop(winit_span);
 
         let mut app = HeadlessApp {
@@ -1027,6 +1108,7 @@ impl App {
             fn resumed(&mut self, winit_loop: &ActiveEventLoop) {
                 let mut winit_loop_guard = self.app.winit_loop.set(winit_loop);
 
+                self.app.resumed(winit_loop);
                 self.app.start_receiving(self.request_receiver.take().unwrap());
 
                 'app_loop: while !self.app.exited {
@@ -1096,16 +1178,27 @@ impl App {
             }
 
             fn window_event(&mut self, _: &ActiveEventLoop, _: winit::window::WindowId, _: WindowEvent) {}
+
+            fn suspended(&mut self, event_loop: &ActiveEventLoop) {
+                self.app.suspended(event_loop);
+            }
         }
     }
 
-    pub fn run_headed(ipc: ipc::ViewChannels, ext: ViewExtensions) {
+    pub fn run_headed(
+        #[cfg(target_os = "android")] android_app: platform::android::activity::AndroidApp,
+        ipc: ipc::ViewChannels,
+        ext: ViewExtensions,
+    ) {
         tracing::info!("running headed view-process");
 
         gl::warmup();
 
         let winit_span = tracing::trace_span!("winit::EventLoop::new").entered();
+        #[cfg(not(target_os = "android"))]
         let event_loop = EventLoop::with_user_event().build().unwrap();
+        #[cfg(target_os = "android")]
+        let event_loop = EventLoop::with_user_event().with_android_app(android_app).build().unwrap();
         drop(winit_span);
         let app_sender = event_loop.create_proxy();
 
@@ -1135,16 +1228,14 @@ impl App {
         response_sender: ipc::ResponseSender,
         event_sender: ipc::EventSender,
         request_recv: flume::Receiver<RequestEvent>,
-        mut ext: ViewExtensions,
+        mut exts: ViewExtensions,
     ) -> Self {
-        ext.renderer("zng-view.webrender_debug", extensions::RendererDebugExt::new);
-        ext.init(&app_sender);
+        exts.renderer("zng-view.webrender_debug", extensions::RendererDebugExt::new);
         let mut idle = IdleTrace(None);
         idle.enter();
         App {
             headless: false,
-            started: false,
-            exts: ext,
+            exts,
             idle,
             gl_manager: GlContextManager::default(),
             image_cache: ImageCache::new(app_sender.clone()),
@@ -1153,7 +1244,7 @@ impl App {
             response_sender,
             event_sender,
             winit_loop: util::WinitEventLoop::default(),
-            gen: ViewProcessGen::INVALID,
+            generation: ViewProcessGen::INVALID,
             device_events: false,
             windows: vec![],
             surfaces: vec![],
@@ -1165,6 +1256,7 @@ impl App {
             resize_frame_wait_id_gen: FrameWaitId::INVALID,
             coalescing_event: None,
             cursor_entered_expect_move: Vec::with_capacity(1),
+            app_state: AppState::PreInitSuspended,
             exited: false,
             #[cfg(windows)]
             skip_ralt: false,
@@ -1173,7 +1265,7 @@ impl App {
             pending_modifiers_focus_clear: false,
             config_listener_exit: None,
 
-            #[cfg(not(windows))]
+            #[cfg(not(any(windows, target_os = "android")))]
             arboard: None,
         }
     }
@@ -1411,14 +1503,13 @@ impl App {
         }
     }
 
-    fn assert_started(&self) {
-        if !self.started {
-            panic!("not started")
-        }
+    #[track_caller]
+    fn assert_resumed(&self) {
+        assert!(matches!(self.app_state, AppState::Resumed));
     }
 
     fn with_window<R>(&mut self, id: WindowId, action: impl FnOnce(&mut Window) -> R, not_found: impl FnOnce() -> R) -> R {
-        self.assert_started();
+        self.assert_resumed();
         self.windows.iter_mut().find(|w| w.id() == id).map(action).unwrap_or_else(|| {
             tracing::error!("headed window `{id:?}` not found, will return fallback result");
             not_found()
@@ -1448,8 +1539,6 @@ impl App {
     fn available_monitors(&mut self) -> Vec<(MonitorId, MonitorInfo)> {
         let _span = tracing::trace_span!("available_monitors").entered();
 
-        self.assert_started();
-
         let primary = self.winit_loop.primary_monitor();
         self.winit_loop
             .available_monitors()
@@ -1484,9 +1573,9 @@ impl Drop for App {
 }
 impl App {
     fn open_headless_impl(&mut self, config: HeadlessRequest) -> HeadlessOpenData {
-        self.assert_started();
+        self.assert_resumed();
         let surf = Surface::open(
-            self.gen,
+            self.generation,
             config,
             &self.winit_loop,
             &mut self.gl_manager,
@@ -1500,7 +1589,7 @@ impl App {
         HeadlessOpenData { render_mode }
     }
 
-    #[cfg(not(windows))]
+    #[cfg(not(any(windows, target_os = "android")))]
     fn arboard(&mut self) -> Result<&mut arboard::Clipboard, clipboard::ClipboardError> {
         if self.arboard.is_none() {
             match arboard::Clipboard::new() {
@@ -1514,14 +1603,11 @@ impl App {
 
 impl Api for App {
     fn init(&mut self, gen: ViewProcessGen, is_respawn: bool, device_events: bool, headless: bool) {
-        if self.started {
-            panic!("already started");
-        }
         if self.exited {
             panic!("cannot restart exited");
         }
-        self.started = true;
-        self.gen = gen;
+
+        self.generation = gen;
         self.device_events = device_events;
         self.headless = headless;
 
@@ -1544,8 +1630,7 @@ impl Api for App {
     }
 
     fn exit(&mut self) {
-        self.assert_started();
-        self.started = false;
+        self.assert_resumed();
         self.exited = true;
         if let Some(t) = self.config_listener_exit.take() {
             t();
@@ -1588,11 +1673,11 @@ impl Api for App {
 
             self.notify(Event::WindowOpened(id, msg));
         } else {
-            self.assert_started();
+            self.assert_resumed();
 
             let id = config.id;
             let win = Window::open(
-                self.gen,
+                self.generation,
                 config.icon.and_then(|i| self.image_cache.get(i)).and_then(|i| i.icon()),
                 config
                     .cursor_image
@@ -1632,7 +1717,7 @@ impl Api for App {
     fn close(&mut self, id: WindowId) {
         let _s = tracing::debug_span!("close_window", ?id);
 
-        self.assert_started();
+        self.assert_resumed();
         if let Some(i) = self.windows.iter().position(|w| w.id() == id) {
             let _ = self.windows.swap_remove(i);
         }
@@ -1687,7 +1772,7 @@ impl Api for App {
     }
 
     fn set_headless_size(&mut self, renderer: WindowId, size: DipSize, scale_factor: Factor) {
-        self.assert_started();
+        self.assert_resumed();
         if let Some(surf) = self.surfaces.iter_mut().find(|s| s.id() == renderer) {
             surf.set_size(size, scale_factor)
         }
@@ -1935,7 +2020,7 @@ impl Api for App {
         }
     }
 
-    #[cfg(not(windows))]
+    #[cfg(not(any(windows, target_os = "android")))]
     fn read_clipboard(&mut self, data_type: clipboard::ClipboardType) -> Result<clipboard::ClipboardData, clipboard::ClipboardError> {
         match data_type {
             clipboard::ClipboardType::Text => self
@@ -1966,7 +2051,7 @@ impl Api for App {
         }
     }
 
-    #[cfg(not(windows))]
+    #[cfg(not(any(windows, target_os = "android")))]
     fn write_clipboard(&mut self, data: clipboard::ClipboardData) -> Result<(), clipboard::ClipboardError> {
         match data {
             clipboard::ClipboardData::Text(t) => self.arboard()?.set_text(t).map_err(util::arboard_to_clip),
@@ -1994,6 +2079,22 @@ impl Api for App {
         }
     }
 
+    #[cfg(target_os = "android")]
+    fn read_clipboard(&mut self, data_type: clipboard::ClipboardType) -> Result<clipboard::ClipboardData, clipboard::ClipboardError> {
+        let _ = data_type;
+        Err(clipboard::ClipboardError::Other(Txt::from_static(
+            "clipboard not implemented for Android",
+        )))
+    }
+
+    #[cfg(target_os = "android")]
+    fn write_clipboard(&mut self, data: clipboard::ClipboardData) -> Result<(), clipboard::ClipboardError> {
+        let _ = data;
+        Err(clipboard::ClipboardError::Other(Txt::from_static(
+            "clipboard not implemented for Android",
+        )))
+    }
+
     fn set_system_shutdown_warn(&mut self, id: WindowId, reason: Txt) {
         self.with_window(id, move |w| w.set_system_shutdown_warn(reason), || ())
     }
@@ -2144,3 +2245,6 @@ impl RenderNotifier for WrNotifier {
         let _ = self.sender.frame_ready(self.id, msg);
     }
 }
+
+#[cfg(target_arch = "wasm32")]
+compile_error!("zng-view does not support Wasm");
diff --git a/crates/zng-view/src/platform.rs b/crates/zng-view/src/platform.rs
new file mode 100644
index 000000000..7aae1db1a
--- /dev/null
+++ b/crates/zng-view/src/platform.rs
@@ -0,0 +1,10 @@
+//! Platform specific types.
+
+/// Android backend.
+///
+/// See [`winit::platform::android`](https://docs.rs/winit/latest/winit/platform/android/) for more details
+/// on how to select a backend "Activity".
+#[cfg(target_os = "android")]
+pub mod android {
+    pub use winit::platform::android::activity;
+}
diff --git a/crates/zng-view/src/util.rs b/crates/zng-view/src/util.rs
index 02bf684d6..f085ca6c3 100644
--- a/crates/zng-view/src/util.rs
+++ b/crates/zng-view/src/util.rs
@@ -12,7 +12,6 @@ use winit::{event::ElementState, monitor::MonitorHandle};
 use zng_txt::{ToTxt, Txt};
 use zng_unit::*;
 use zng_view_api::access::AccessNodeId;
-use zng_view_api::clipboard as clipboard_api;
 use zng_view_api::keyboard::{KeyLocation, NativeKeyCode};
 use zng_view_api::window::{FrameCapture, FrameRequest, FrameUpdateRequest, ResizeDirection, WindowButton};
 use zng_view_api::{
@@ -22,6 +21,9 @@ use zng_view_api::{
     window::{CursorIcon, MonitorInfo, VideoMode},
 };
 
+#[cfg(not(target_os = "android"))]
+use zng_view_api::clipboard as clipboard_api;
+
 /// Sets a window subclass that calls a raw event handler.
 ///
 /// Use this to receive Windows OS events not covered in [`raw_events`].
@@ -935,11 +937,11 @@ thread_local! {
 }
 
 /// If `true` our custom panic hook must not log anything.
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 pub(crate) fn suppress_panic() -> bool {
     SUPPRESS.get()
 }
-#[cfg(feature = "ipc")]
+#[cfg(ipc)]
 pub(crate) fn set_suppressed_panic(panic: SuppressedPanic) {
     SUPPRESSED_PANIC.set(Some(panic));
 }
@@ -954,7 +956,7 @@ pub(crate) struct SuppressedPanic {
     pub backtrace: Backtrace,
 }
 impl SuppressedPanic {
-    #[cfg(feature = "ipc")]
+    #[cfg(ipc)]
     #[allow(deprecated)] // std::panic::PanicInfo is deprecated on nightly (>=1.81)
     pub fn from_hook(info: &std::panic::PanicInfo, backtrace: Backtrace) -> Self {
         let current_thread = std::thread::current();
@@ -1133,7 +1135,7 @@ pub(crate) fn wr_workers() -> Arc<rayon::ThreadPool> {
     Arc::new(worker.unwrap())
 }
 
-#[cfg(not(windows))]
+#[cfg(not(any(windows, target_os = "android")))]
 pub(crate) fn arboard_to_clip(e: arboard::Error) -> clipboard_api::ClipboardError {
     match e {
         arboard::Error::ContentNotAvailable => clipboard_api::ClipboardError::NotFound,
@@ -1591,7 +1593,7 @@ pub(crate) fn frame_update_render_reasons(update: &FrameUpdateRequest) -> wr::Re
     reasons
 }
 
-#[must_use = "call .unset before drop"]
+#[must_use = "call unset before drop"]
 pub(crate) struct WinitEventLoop(*const ActiveEventLoop);
 impl WinitEventLoop {
     pub fn set<'l>(&mut self, winit_loop: &'l ActiveEventLoop) -> WinitEventLoopGuard<'l> {
diff --git a/crates/zng-view/src/window.rs b/crates/zng-view/src/window.rs
index aff81a15a..48f7a9b44 100644
--- a/crates/zng-view/src/window.rs
+++ b/crates/zng-view/src/window.rs
@@ -1,13 +1,10 @@
 use std::{
     collections::VecDeque,
-    fmt,
-    future::Future,
-    mem,
+    fmt, mem,
     sync::{
         atomic::{AtomicBool, Ordering},
         Arc,
     },
-    thread,
 };
 
 use tracing::span::EnteredSpan;
@@ -163,7 +160,7 @@ impl Window {
         let mut winit = WindowAttributes::default()
             .with_title(cfg.title)
             .with_resizable(cfg.resizable)
-            .with_transparent(cfg.transparent)
+            .with_transparent(cfg.transparent && cfg!(not(target_os = "android")))
             .with_window_icon(cfg_icon);
 
         let mut s = cfg.state;
@@ -237,7 +234,7 @@ impl Window {
             // so that there is no white frame when it's opening.
             //
             // unless its "kiosk" mode.
-            .with_visible(cfg.kiosk || matches!(s.state, WindowState::Exclusive));
+            .with_visible(cfg!(target_os = "android") || cfg.kiosk || matches!(s.state, WindowState::Exclusive));
 
         let mut render_mode = cfg.render_mode;
         if !cfg!(feature = "software") && render_mode == RenderMode::Software {
@@ -361,6 +358,12 @@ impl Window {
             // extensions expect this to be set.
             workers: Some(crate::util::wr_workers()),
 
+            // rendering is broken on Android emulators with unoptimized shaders.
+            // see: https://github.com/servo/servo/pull/31727
+            // webrender issue: https://bugzilla.mozilla.org/show_bug.cgi?id=1887337
+            #[cfg(target_os = "android")]
+            use_optimized_shaders: true,
+
             //panic_on_gl_error: true,
             ..Default::default()
         };
@@ -1710,6 +1713,7 @@ impl Window {
         ApiExtensionPayload::unknown_extension(extension_id)
     }
 
+    #[cfg(not(target_os = "android"))]
     fn enter_dialog(&self, id: dlg_api::DialogId, event_sender: &AppEventSender) -> bool {
         let already_open = self.modal_dialog_active.swap(true, Ordering::Acquire);
         if already_open {
@@ -1721,7 +1725,17 @@ impl Window {
         already_open
     }
 
+    #[cfg(target_os = "android")]
+    pub(crate) fn message_dialog(&self, dialog: dlg_api::MsgDialog, id: dlg_api::DialogId, event_sender: AppEventSender) {
+        let _ = dialog;
+        let _ = event_sender.send(AppEvent::Notify(Event::MsgDialogResponse(
+            id,
+            dlg_api::MsgDialogResponse::Error(Txt::from_static("native dialogs not implemented for android")),
+        )));
+    }
+
     /// Shows a native message dialog.
+    #[cfg(not(target_os = "android"))]
     pub(crate) fn message_dialog(&self, dialog: dlg_api::MsgDialog, id: dlg_api::DialogId, event_sender: AppEventSender) {
         if self.enter_dialog(id, &event_sender) {
             return;
@@ -1768,7 +1782,17 @@ impl Window {
         });
     }
 
+    #[cfg(target_os = "android")]
+    pub(crate) fn file_dialog(&self, dialog: dlg_api::FileDialog, id: dlg_api::DialogId, event_sender: AppEventSender) {
+        let _ = dialog;
+        let _ = event_sender.send(AppEvent::Notify(Event::MsgDialogResponse(
+            id,
+            dlg_api::MsgDialogResponse::Error(Txt::from_static("native dialogs not implemented for android")),
+        )));
+    }
+
     /// Shows a native file dialog.
+    #[cfg(not(target_os = "android"))]
     pub(crate) fn file_dialog(&self, dialog: dlg_api::FileDialog, id: dlg_api::DialogId, event_sender: AppEventSender) {
         if self.enter_dialog(id, &event_sender) {
             return;
@@ -1816,21 +1840,22 @@ impl Window {
         });
     }
     /// Run dialog unblocked.
-    fn run_dialog(run: impl Future + Send + 'static) {
+    #[cfg(not(target_os = "android"))]
+    fn run_dialog(run: impl std::future::Future + Send + 'static) {
         let mut task = Box::pin(run);
-        thread::spawn(move || {
-            struct ThreadWaker(thread::Thread);
+        std::thread::spawn(move || {
+            struct ThreadWaker(std::thread::Thread);
             impl std::task::Wake for ThreadWaker {
                 fn wake(self: std::sync::Arc<Self>) {
                     self.0.unpark();
                 }
             }
-            let waker = Arc::new(ThreadWaker(thread::current())).into();
+            let waker = Arc::new(ThreadWaker(std::thread::current())).into();
             let mut cx = std::task::Context::from_waker(&waker);
             loop {
                 match task.as_mut().poll(&mut cx) {
                     std::task::Poll::Ready(_) => return,
-                    std::task::Poll::Pending => thread::park(),
+                    std::task::Poll::Pending => std::thread::park(),
                 }
             }
         });
diff --git a/crates/zng-wgt-inspector/Cargo.toml b/crates/zng-wgt-inspector/Cargo.toml
index c0f3c070e..f0d547a99 100644
--- a/crates/zng-wgt-inspector/Cargo.toml
+++ b/crates/zng-wgt-inspector/Cargo.toml
@@ -18,6 +18,8 @@ debug_default = []
 # Compiles the interactive inspector.
 live = []
 # Compiles the debug crash handler.
+#
+# Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 crash_handler = ["zng-app/crash_handler", "dep:open"]
 
 [dependencies]
diff --git a/crates/zng-wgt-inspector/README.md b/crates/zng-wgt-inspector/README.md
index 764dc34fa..06c860e9e 100644
--- a/crates/zng-wgt-inspector/README.md
+++ b/crates/zng-wgt-inspector/README.md
@@ -18,6 +18,8 @@ Compiles the interactive inspector.
 #### `"crash_handler"`
 Compiles the debug crash handler.
 
+Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
+
 <!--do doc --readme #SECTION-END-->
 
 
diff --git a/crates/zng-wgt-inspector/src/crash_handler.rs b/crates/zng-wgt-inspector/src/crash_handler.rs
index e48d30d31..d229a9960 100644
--- a/crates/zng-wgt-inspector/src/crash_handler.rs
+++ b/crates/zng-wgt-inspector/src/crash_handler.rs
@@ -1,4 +1,4 @@
-#![cfg(all(feature = "crash_handler", not(target_arch = "wasm32")))]
+#![cfg(all(feature = "crash_handler", not(any(target_arch = "wasm32", target_os = "android"))))]
 
 //! Debug crash handler.
 
diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml
index 43ffa98d0..df1c53ea9 100644
--- a/crates/zng/Cargo.toml
+++ b/crates/zng/Cargo.toml
@@ -16,11 +16,11 @@ default = ["ipc", "debug_default", "view_software", "crash_handler"]
 
 # Include the default view-process implementation.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(target_arch = "wasm32")` builds.
 view = ["dep:zng-view"]
 # Include the default view-process implementation as an embedded precompiled binary.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 view_prebuilt = ["dep:zng-view-prebuilt", "ipc"]
 
 # Enables HTTP tasks and web features of widgets and services.
@@ -52,7 +52,7 @@ inspector = ["zng-app/inspector", "zng-wgt-inspector/live"]
 #
 # Note that you must configure the target library to hot reload, see `zng::hot_reload` for details.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 hot_reload = ["dep:zng-ext-hot-reload"]
 
 # Use dynamic dispatch at the app-extension level.
@@ -90,14 +90,14 @@ multi_app = ["zng-app/multi_app"]
 # Builds with this feature only allow one app-process, subsequent attempts to spawn the app redirect to
 # the running app-process.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 single_instance = ["dep:zng-ext-single-instance"]
 
 # Allow app-process crash handler.
 #
 # Builds with this feature spawn a crash monitor-process for each app-process.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 crash_handler = ["zng-app/crash_handler", "zng-wgt-inspector/crash_handler"]
 
 # Instrument every widget outer-most node to trace UI methods.
@@ -174,7 +174,7 @@ view_bundle_licenses = ["zng-view?/bundle_licenses"]
 
 # Enables IPC tasks, pre-build views and connecting to views running in another process.
 #
-# Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+# Only enables in `not(any(target_os = "android", target_arch = "wasm32"))` builds.
 ipc = ["zng-app/ipc", "zng-view-api/ipc", "zng-view?/ipc", "zng-task/ipc"]
 
 # Check if `zng::env::res` path is available in `init_built_res` first.
@@ -182,6 +182,16 @@ ipc = ["zng-app/ipc", "zng-view-api/ipc", "zng-view?/ipc", "zng-task/ipc"]
 # Enabled by default in debug builds.
 built_res = ["zng-env/built_res"]
 
+# Standard Android backend that requires a build system that can compile Java or Kotlin and fetch Android dependencies.
+#
+# See `https://docs.rs/winit/latest/winit/platform/android/` for more details.
+android_game_activity = ["zng-view?/android_game_activity"]
+
+# Basic Android backend that does not require Java.
+#
+# See `https://docs.rs/winit/latest/winit/platform/android/` for more details. 
+android_native_activity = ["zng-view?/android_native_activity"]
+
 [dependencies]
 # foundation
 zng-clone-move = { path = "../zng-clone-move", version = "0.2.5" }
@@ -251,10 +261,16 @@ tracing = "0.1"
 
 # view
 zng-view-api = { path = "../zng-view-api", version = "0.6.5", default-features = false }
+
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 zng-view = { path = "../zng-view", version = "0.4.11", default-features = false, optional = true }
+
+[target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies]
 zng-view-prebuilt = { path = "../zng-view-prebuilt", version = "0.11.0", optional = true }
 
+[build-dependencies]
+cfg_aliases = "0.2"
+
 [package.metadata.docs.rs]
 all-features = true
 
diff --git a/crates/zng/README.md b/crates/zng/README.md
index 082cb544c..74a7d8ff3 100644
--- a/crates/zng/README.md
+++ b/crates/zng/README.md
@@ -60,17 +60,17 @@ The example above installs `cargo-zng` and uses it to generate a new './my-app'
 <!--do doc --readme features-->
 ## Cargo Features
 
-This crate provides 29 feature flags, 4 enabled by default.
+This crate provides 31 feature flags, 4 enabled by default.
 
 #### `"view"`
 Include the default view-process implementation.
 
-Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+Only enables in `not(target_arch = "wasm32")` builds.
 
 #### `"view_prebuilt"`
 Include the default view-process implementation as an embedded precompiled binary.
 
-Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 
 #### `"http"`
 Enables HTTP tasks and web features of widgets and services.
@@ -94,6 +94,8 @@ Enable hot reload builds.
 
 Note that you must configure the target library to hot reload, see `zng::hot_reload` for details.
 
+Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
+
 #### `"dyn_app_extension"`
 Use dynamic dispatch at the app-extension level.
 
@@ -122,12 +124,14 @@ Enables single app-process instance mode.
 Builds with this feature only allow one app-process, subsequent attempts to spawn the app redirect to
 the running app-process.
 
+Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
+
 #### `"crash_handler"`
 Allow app-process crash handler.
 
 Builds with this feature spawn a crash monitor-process for each app-process.
 
-Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+Only enables in `not(any(target_arch = "wasm32", target_os = "android"))` builds.
 
 *Enabled by default.*
 
@@ -191,7 +195,7 @@ Not enabled by default. Note that `"view_prebuilt"` always bundles licenses.
 #### `"ipc"`
 Enables IPC tasks, pre-build views and connecting to views running in another process.
 
-Only enabled in `cfg(not(target_arch = "wasm32"))` builds.
+Only enables in `not(any(target_os = "android", target_arch = "wasm32"))` builds.
 
 *Enabled by default.*
 
@@ -200,6 +204,16 @@ Check if `zng::env::res` path is available in `init_built_res` first.
 
 Enabled by default in debug builds.
 
+#### `"android_game_activity"`
+Standard Android backend that requires a build system that can compile Java or Kotlin and fetch Android dependencies.
+
+See `https://docs.rs/winit/latest/winit/platform/android/` for more details.
+
+#### `"android_native_activity"`
+Basic Android backend that does not require Java.
+
+See `https://docs.rs/winit/latest/winit/platform/android/` for more details.
+
 <!--do doc --readme #SECTION-END-->
 
 ## Repository
diff --git a/crates/zng/build.rs b/crates/zng/build.rs
index ae32701d2..88e24814c 100644
--- a/crates/zng/build.rs
+++ b/crates/zng/build.rs
@@ -1,4 +1,17 @@
+#![recursion_limit = "512"]
+
 fn main() {
+    cfg_aliases::cfg_aliases! {
+        wasm: { target_arch = "wasm32" },
+        android: { target_os = "android" },
+        ipc: { all(feature="ipc", not(any(android, wasm))) },
+        view: { all(feature="view", not(wasm)) },
+        view_prebuilt: { all(feature="view_prebuilt", not(any(android, wasm))) },
+        hot_reload: { all(feature="hot_reload", not(any(android, wasm))) },
+        single_instance: { all(feature="single_instance", not(any(android, wasm))) },
+        crash_handler: { all(feature="crash_handler", not(any(android, wasm))) },
+    }
+
     macro_rules! enable {
         ($feature:tt) => {
             if !cfg!(feature = $feature) {
diff --git a/crates/zng/src/app.rs b/crates/zng/src/app.rs
index ead8ae013..4b82085dc 100644
--- a/crates/zng/src/app.rs
+++ b/crates/zng/src/app.rs
@@ -427,7 +427,7 @@ pub mod raw_device_events {
     };
 }
 
-#[cfg(feature = "single_instance")]
+#[cfg(single_instance)]
 pub use zng_ext_single_instance::{AppInstanceArgs, APP_INSTANCE_EVENT};
 
 /// App-process crash handler.
@@ -545,7 +545,7 @@ pub use zng_ext_single_instance::{AppInstanceArgs, APP_INSTANCE_EVENT};
 /// # Full API
 ///
 /// See [`zng_app::crash_handler`] and [`zng_wgt_inspector::crash_handler`] for the full API.
-#[cfg(all(feature = "crash_handler", not(target_arch = "wasm32")))]
+#[cfg(crash_handler)]
 pub mod crash_handler {
     pub use zng_app::crash_handler::{crash_handler_config, BacktraceFrame, CrashArgs, CrashConfig, CrashError, CrashPanic};
 
diff --git a/crates/zng/src/hot_reload.rs b/crates/zng/src/hot_reload.rs
index 01aa624b0..ecb4cb254 100644
--- a/crates/zng/src/hot_reload.rs
+++ b/crates/zng/src/hot_reload.rs
@@ -146,7 +146,7 @@
 /// The function output type must be `impl UiNode`, the attribute will change the internal node type.
 ///
 /// [`property`]: crate::widget::property#input-types
-#[cfg(feature = "hot_reload")]
+#[cfg(hot_reload)]
 pub use zng_ext_hot_reload::hot_node;
 
 /// Declare the dynamic library hot reload entry.
@@ -155,10 +155,10 @@ pub use zng_ext_hot_reload::hot_node;
 /// documentation for more details.
 ///
 /// [module]: crate::hot_reload
-#[cfg(feature = "hot_reload")]
+#[cfg(hot_reload)]
 pub use zng_ext_hot_reload::zng_hot_entry;
 
-#[cfg(feature = "hot_reload")]
+#[cfg(hot_reload)]
 pub use zng_ext_hot_reload::{BuildArgs, BuildError, HOT_RELOAD};
 
 pub use zng_unique_id::{hot_static, hot_static_ref, lazy_static, lazy_static_init};
diff --git a/crates/zng/src/lib.rs b/crates/zng/src/lib.rs
index f14eb111c..6a584156a 100644
--- a/crates/zng/src/lib.rs
+++ b/crates/zng/src/lib.rs
@@ -797,7 +797,7 @@ mod __prelude_wgt {
 
     pub use zng_ext_window::WidgetInfoBuilderImeArea as _;
 
-    #[cfg(feature = "hot_reload")]
+    #[cfg(hot_reload)]
     pub use crate::hot_reload::hot_node;
 }
 
@@ -880,13 +880,13 @@ mod defaults {
                 .extend(ClipboardManager::default())
                 .extend(UndoManager::default());
 
-            #[cfg(all(feature = "view", feature = "view_prebuilt"))]
+            #[cfg(all(view, view_prebuilt))]
             tracing::debug!(r#"both "view" and "view_prebuilt" enabled, will use only one, indeterminate witch"#);
 
-            #[cfg(all(feature = "single_instance", not(target_arch = "wasm32")))]
+            #[cfg(single_instance)]
             let r = r.extend(zng_ext_single_instance::SingleInstanceManager::default());
 
-            #[cfg(all(feature = "hot_reload", not(target_arch = "wasm32")))]
+            #[cfg(hot_reload)]
             let r = r.extend(zng_ext_hot_reload::HotReloadManager::default());
 
             #[cfg(any(
@@ -927,7 +927,7 @@ mod defaults {
             zng_wgt_settings::handle_settings_cmd();
             tracing::debug!("defaults init, settings set");
 
-            #[cfg(feature = "single_instance")]
+            #[cfg(single_instance)]
             {
                 crate::app::APP_INSTANCE_EVENT
                     .on_pre_event(crate::handler::app_hn!(|args: &crate::app::AppInstanceArgs, _| {
diff --git a/crates/zng/src/task.rs b/crates/zng/src/task.rs
index fd4cea665..94fcd431e 100644
--- a/crates/zng/src/task.rs
+++ b/crates/zng/src/task.rs
@@ -155,7 +155,7 @@ pub use zng_task::{doc_test, spin_on};
 #[cfg(feature = "http")]
 pub use zng_task::http;
 
-#[cfg(all(feature = "ipc", not(target_arch = "wasm32")))]
+#[cfg(ipc)]
 pub use zng_task::ipc;
 
 #[doc(no_inline)]
diff --git a/crates/zng/src/view_process.rs b/crates/zng/src/view_process.rs
index 40a97a683..81d452739 100644
--- a/crates/zng/src/view_process.rs
+++ b/crates/zng/src/view_process.rs
@@ -36,9 +36,22 @@
 /// # Full API
 ///
 /// See [`zng_view`] for the full API.
-#[cfg(all(feature = "view", not(target_arch = "wasm32")))]
+#[cfg(view)]
 pub mod default {
     pub use zng_view::run_same_process;
+
+    /// Android backend required types.
+    ///
+    /// See [`winit::platform::android`](https://docs.rs/winit/latest/winit/platform/android/) for more details
+    /// on how to select a backend "Activity".
+    ///
+    /// # Full API
+    ///
+    /// See [`zng_view::platform::android`] for the full API.
+    #[cfg(target_os = "android")]
+    pub mod android {
+        pub use zng_view::platform::android::activity::AndroidApp;
+    }
 }
 
 /// Default view-process implementation as an embedded precompiled binary.
@@ -46,7 +59,7 @@ pub mod default {
 /// # Full API
 ///
 /// See [`zng_view_prebuilt`] and [`zng_view`] for the full API.
-#[cfg(all(feature = "view_prebuilt", not(target_arch = "wasm32")))]
+#[cfg(view_prebuilt)]
 pub mod prebuilt {
     pub use zng_view_prebuilt::run_same_process;
 }
diff --git a/examples/README.md b/examples/README.md
index f4edb562e..c2516741a 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -204,6 +204,27 @@ cargo do run markdown
 
 Demonstrates the `Markdown!` widget.
 
+### `multi`
+
+<img alt='multi screenshot' src='./multi/res/screenshot.png' width='300'>
+
+Source: [multi/src](./multi/src)
+
+```console
+cargo do run multi
+```
+
+Demonstrates a web, mobile and desktop app setup.
+
+Use `cargo do run multi` to run on the desktop.
+
+Use `cargo do build-apk multi` to build a package and Android Studio "Profile or Debug APK" to run on a device.
+
+Use `cargo do run-wasm multi` to run on the browser.
+
+Note that web support is very limited, only a small subset of services are supported and
+only headless (without renderer) apps can run.
+
 ### `respawn`
 
 <img alt='respawn screenshot' src='./respawn/res/screenshot.png' width='300'>
@@ -264,20 +285,6 @@ cargo do run transform
 
 Demonstrates 2D and 3D transforms, touch transforms.
 
-### `web`
-
-Source: [web/src](./web/src)
-
-```console
-cargo do run web
-```
-
-Demonstrates a hybrid web and desktop app setup.
-
-Note that only a small subset of services are supported and only headless (without renderer) apps can run.
-
-Use `cargo do run-wasm web` to run on the browser and `cargo do run web` to run standalone.
-
 ### `window`
 
 <img alt='window screenshot' src='./window/res/screenshot.png' width='300'>
diff --git a/examples/web/Cargo.toml b/examples/multi/Cargo.toml
similarity index 75%
rename from examples/web/Cargo.toml
rename to examples/multi/Cargo.toml
index 41a4b15c1..e35ff1dbf 100644
--- a/examples/web/Cargo.toml
+++ b/examples/multi/Cargo.toml
@@ -1,5 +1,5 @@
 [package]
-name = "zng-example-web"
+name = "zng-example-multi"
 version = "0.0.0"
 publish = false
 edition = "2021"
@@ -8,7 +8,7 @@ edition = "2021"
 crate-type = ["cdylib"]
 
 [dependencies]
-zng = { path = "../../crates/zng", features = ["view_prebuilt", "material_icons"] }
+zng = { path = "../../crates/zng", features = ["view", "android_native_activity", "material_icons"] }
 tracing = "0.1"
 
 zng-env = { path = "../../crates/zng-env" }
diff --git a/examples/multi/build.rs b/examples/multi/build.rs
new file mode 100644
index 000000000..5389fe4bc
--- /dev/null
+++ b/examples/multi/build.rs
@@ -0,0 +1,25 @@
+use std::{
+    env, fs,
+    path::{Path, PathBuf},
+};
+
+fn main() {
+    if env::var("CARGO_CFG_TARGET_OS").unwrap() == "android" {
+        android();
+    }
+}
+
+fn android() {
+    println!("cargo:rustc-link-lib=c++_shared");
+
+    if let Ok(output_path) = env::var("CARGO_NDK_OUTPUT_PATH") {
+        let sysroot_libs_path = PathBuf::from(env::var_os("CARGO_NDK_SYSROOT_LIBS_PATH").unwrap());
+        let lib_path = sysroot_libs_path.join("libc++_shared.so");
+
+        let output_path = Path::new(&output_path).join(env::var("CARGO_NDK_ANDROID_TARGET").unwrap());
+        let _ = fs::create_dir_all(&output_path);
+        let output_path = output_path.join("libc++_shared.so");
+        std::fs::copy(lib_path, &output_path).unwrap();
+        println!("cargo:rerun-if-changed={}", output_path.display());
+    }
+}
diff --git a/examples/multi/res/screenshot.png b/examples/multi/res/screenshot.png
new file mode 100644
index 000000000..e266ed5e6
Binary files /dev/null and b/examples/multi/res/screenshot.png differ
diff --git a/examples/multi/src/app.rs b/examples/multi/src/app.rs
new file mode 100644
index 000000000..7df314060
--- /dev/null
+++ b/examples/multi/src/app.rs
@@ -0,0 +1,58 @@
+use zng::prelude::*;
+
+#[allow(unused)]
+pub fn run() {
+    APP.defaults().run_window(async {
+        let count = var(0u32);
+        Window! {
+            child = Button! {
+                style_fn = zng::button::LightStyle!();
+                child_align = Align::TOP;
+                child = Text! {
+                    txt = count.map(|&c| {
+                        let mut txt = formatx!("HELLO {}!", std::env::consts::OS.to_uppercase());
+                        if c > 0 {
+                            use std::fmt::Write;
+                            write!(&mut txt, "\n{c}");
+                        }
+                        txt
+                    });
+                    txt_align = Align::CENTER;
+                    layout::padding = 20;
+                    font_family = "fantasy";
+                    font_size = 2.em();
+                    font_weight = FontWeight::BOLD;
+                    font_style = FontStyle::Italic;
+
+                };
+                on_click = hn!(|_| {
+                    let c = count.get() + 1;
+                    tracing::info!("Clicked {c} times!");
+                    count.set(c);
+                });
+            };
+            color_scheme = color::ColorScheme::Dark;
+            widget::background_gradient = {
+                axis: 180.deg(),
+                stops: color::gradient::stops![
+                    (hex!(#1F214D), 0.pct()),
+                    (hex!(#50366F), 80.pct()),
+                    (hex!(#BF3475), 90.pct()),
+                    (hex!(#EE6C45), 99.pct()),
+                    (hex!(#FFCE61), 100.pct()),
+                ],
+            };
+        }
+    });
+}
+
+#[allow(unused)]
+pub fn run_headless() {
+    let _app = APP.minimal().run_headless(false);
+
+    tracing::info!("Debug tracing logs to console");
+    tracing::warn!("Warn example!");
+    tracing::error!("Error example!");
+
+    let _web_time = INSTANT.epoch();
+}
diff --git a/examples/multi/src/lib.rs b/examples/multi/src/lib.rs
new file mode 100644
index 000000000..837ce7e36
--- /dev/null
+++ b/examples/multi/src/lib.rs
@@ -0,0 +1,52 @@
+#[cfg(any(target_arch = "wasm32", target_os = "android"))]
+mod app;
+
+#[cfg(target_arch = "wasm32")]
+mod wasm {
+    use super::app;
+    use wasm_bindgen::prelude::*;
+
+    #[wasm_bindgen(start)]
+    fn main() {
+        zng::env::init!();
+        app::run_headless();
+
+        let window = web_sys::window().unwrap();
+        let document = window.document().unwrap();
+        let body = document.body().unwrap();
+
+        let val = document.create_element("p").unwrap();
+        val.set_inner_html("Hello from Rust!");
+
+        body.append_child(&val).unwrap();
+    }
+
+    zng::env::on_process_start!(|_| {
+        let window = web_sys::window().unwrap();
+        let document = window.document().unwrap();
+        let body = document.body().unwrap();
+
+        let val = document.create_element("p").unwrap();
+        val.set_inner_html("on_process_start!");
+
+        body.append_child(&val).unwrap();
+    });
+}
+
+#[cfg(target_os = "android")]
+mod android {
+    use super::app;
+
+    #[no_mangle]
+    fn android_main(app: zng::view_process::default::android::AndroidApp) {
+        zng::env::init!();
+        zng::app::print_tracing(tracing::Level::INFO);
+
+        if let Some(p) = app.internal_data_path() {
+            zng::env::init_config(p);
+        }
+
+        tracing::info!("Hello Android!");
+        zng::view_process::default::run_same_process(app, app::run);
+    }
+}
diff --git a/examples/multi/src/main.rs b/examples/multi/src/main.rs
new file mode 100644
index 000000000..fa1f98b20
--- /dev/null
+++ b/examples/multi/src/main.rs
@@ -0,0 +1,17 @@
+//! Demonstrates a web, mobile and desktop app setup.
+//!
+//! Use `cargo do run multi` to run on the desktop.
+//!
+//! Use `cargo do build-apk multi` to build a package and Android Studio "Profile or Debug APK" to run on a device.
+//!
+//! Use `cargo do run-wasm multi` to run on the browser.
+//!
+//! Note that web support is very limited, only a small subset of services are supported and
+//! only headless (without renderer) apps can run.
+
+mod app;
+
+fn main() {
+    zng::env::init!();
+    app::run();
+}
diff --git a/examples/web/src/app.rs b/examples/web/src/app.rs
deleted file mode 100644
index 4e87984df..000000000
--- a/examples/web/src/app.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-use zng::prelude::*;
-
-pub fn run() {
-    zng::env::init!();
-
-    let _app = APP.minimal().run_headless(false);
-
-    tracing::info!("Debug tracing logs to console");
-    tracing::warn!("Warn example!");
-    tracing::error!("Error example!");
-
-    let _web_time = INSTANT.epoch();
-}
diff --git a/examples/web/src/lib.rs b/examples/web/src/lib.rs
deleted file mode 100644
index 08d3ea976..000000000
--- a/examples/web/src/lib.rs
+++ /dev/null
@@ -1,30 +0,0 @@
-#![cfg(target_arch = "wasm32")]
-
-mod app;
-
-use wasm_bindgen::prelude::*;
-
-#[wasm_bindgen(start)]
-fn main() {
-    app::run();
-
-    let window = web_sys::window().unwrap();
-    let document = window.document().unwrap();
-    let body = document.body().unwrap();
-
-    let val = document.create_element("p").unwrap();
-    val.set_inner_html("Hello from Rust!");
-
-    body.append_child(&val).unwrap();
-}
-
-zng::env::on_process_start!(|_| {
-    let window = web_sys::window().unwrap();
-    let document = window.document().unwrap();
-    let body = document.body().unwrap();
-
-    let val = document.create_element("p").unwrap();
-    val.set_inner_html("on_process_start!");
-
-    body.append_child(&val).unwrap();
-});
diff --git a/examples/web/src/main.rs b/examples/web/src/main.rs
deleted file mode 100644
index 03bd67369..000000000
--- a/examples/web/src/main.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-//! Demonstrates a hybrid web and desktop app setup.
-//!
-//! Note that only a small subset of services are supported and only headless (without renderer) apps can run.
-//!
-//! Use `cargo do run-wasm web` to run on the browser and `cargo do run web` to run standalone.
-
-mod app;
-
-fn main() {
-    app::run();
-}
diff --git a/tools/cargo-do/src/build-apk-manifest.xml b/tools/cargo-do/src/build-apk-manifest.xml
new file mode 100644
index 000000000..39597c53a
--- /dev/null
+++ b/tools/cargo-do/src/build-apk-manifest.xml
@@ -0,0 +1,14 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="zng.example.${EXAMPLE}" android:versionCode="1" android:versionName="0.0">
+
+    <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="29" />
+
+    <application android:label="${EXAMPLE}" android:hasCode="false" android:debuggable="true">
+        <activity android:name="android.app.NativeActivity" android:label="${EXAMPLE}">
+            <meta-data android:name="android.app.lib_name" android:value="${EXAMPLE}" />
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/tools/cargo-do/src/main.rs b/tools/cargo-do/src/main.rs
index 6cde5eb23..ab75efdfa 100644
--- a/tools/cargo-do/src/main.rs
+++ b/tools/cargo-do/src/main.rs
@@ -20,6 +20,7 @@ fn main() {
         "expand" => expand(args),
         "check" | "c" => check(args),
         "build" | "b" => build(args),
+        "build-apk" => build_apk(args),
         "prebuild" => prebuild(args),
         "clean" => clean(args),
         "asm" => asm(args),
@@ -869,6 +870,79 @@ fn build(mut args: Vec<&str>) {
     cmd_env("cargo", &cargo_args, &args, rust_flags);
 }
 
+// do build-apk <EXAMPLE> [--release-lto]
+//    Compile an example for Android using cargo-ndk and cargo zng res (.zr-apk)
+fn build_apk(mut args: Vec<&str>) {
+    let release_lto = take_flag(&mut args, &["--release-lto"]);
+    let e = match args.pop() {
+        Some(e) => e,
+        None => fatal("missing example"),
+    };
+
+    cmd_req("cargo", &["build", "--package", "cargo-zng"], &[]);
+    let cargo_zng = format!("target/debug/cargo-zng{}", std::env::consts::EXE_SUFFIX);
+
+    let src = std::path::Path::new("examples").join(&e).join("src");
+    if src.exists() && !src.join("lib.rs").exists() {
+        fatal(f!("example `{e}` does not support Android target"));
+    }
+
+    let example = format!("zng-example-{e}");
+    let mut rust_flags = std::env::var("RUSTFLAGS").unwrap_or_default();
+    // args required to build linkme (used by zng-env and others)
+    rust_flags.push_str(" -Clink-arg=-z -Clink-arg=nostart-stop-gc");
+
+    // cargo zng res (.zr-apk)
+    let apk_dir = std::path::PathBuf::from(format!("target/build-apk/{example}/source.apk"));
+    let _ = std::fs::remove_file(&apk_dir);
+    let _ = std::fs::remove_dir_all(&apk_dir);
+    let _ = std::fs::create_dir_all(&apk_dir);
+    let apk_dir = dunce::canonicalize(apk_dir).unwrap();
+    let mut build_args = vec!["build", "-p", &example];
+    if release_lto {
+        build_args.push("--profile");
+        build_args.push("release-lto");
+    }
+    cmd_env_req(
+        "cargo",
+        &[
+            "ndk",
+            "-t",
+            "aarch64-linux-android",
+            "-o",
+            apk_dir.join("lib").display().to_string().as_str(),
+        ],
+        &build_args,
+        &[("RUSTFLAGS", rust_flags.as_str())],
+    );
+
+    let manifest = include_str!("build-apk-manifest.xml").replace("${EXAMPLE}", &example.replace('-', "_"));
+
+    std::fs::write(apk_dir.join("AndroidManifest.xml"), manifest.as_bytes()).unwrap();
+    std::fs::write(apk_dir.join("build.zr-apk"), "# generated by 'cargo do build-apk'".as_bytes()).unwrap();
+
+    let target_dir = format!("target/build-apk/{example}.apk");
+    let _ = std::fs::remove_file(&target_dir);
+    let _ = std::fs::remove_dir_all(&target_dir);
+
+    cmd_req(
+        &cargo_zng,
+        &[
+            "zng",
+            "res",
+            "--pack",
+            "--metadata",
+            &format!("examples/{e}/Cargo.toml"),
+            apk_dir.display().to_string().as_str(),
+            &target_dir,
+        ],
+        &[],
+    );
+
+    // cleanup
+    let _ = std::fs::remove_dir_all(apk_dir.parent().unwrap());
+}
+
 // do prebuild
 //    Compile the pre-build `zng-view` release.
 fn prebuild(mut args: Vec<&str>) {


Test patch:
diff --git a/tests/macro-tests/cases/widget_new/unknown_special_value_in_when.stderr b/tests/macro-tests/cases/widget_new/unknown_special_value_in_when.stderr
index 7de52f72d..14cb12bcd 100644
--- a/tests/macro-tests/cases/widget_new/unknown_special_value_in_when.stderr
+++ b/tests/macro-tests/cases/widget_new/unknown_special_value_in_when.stderr
@@ -1,4 +1,4 @@
-error: unexpected token
+error: unexpected token, expected `}`
  --> cases/widget_new/unknown_special_value_in_when.rs:8:25
   |
 8 |             margin = foo!;