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

Issue:
Review Android file system integration
* Test CONFIG saving, resources embedding, icon.
* Android provides an `AndroidApp::internal_data_path`, integrate with `zng::env`?


Discussion / hints:

Fix patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 85e1b70c1..8eaede507 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -89,6 +89,7 @@
         "fct",
         "ffi",
         "ffn",
+        "flate",
         "fnv",
         "Fnv",
         "formatx",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9495cb849..2938585be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Unreleased
 
+* Add `zng::env::android_install_res` helper.
+* Add `zng::env::android_external`.
+* Add `zng_env::android_internal`.
+* Add `zng::view_process::default::android`.
 * Implement Android suspend/resume cycle using the existing "respawn" API.
 * Add `APP.is_suspended` var.
 * Add `VIEW_PROCESS_SUSPENDED_EVENT`.
@@ -10,6 +14,7 @@
 * Implement system fonts query for Android.
 * Implement conversions from `FontStyle`, `FontWeight` and  `FontStretch` to the `ttf-parser` equivalent types.
 * Implement `PartialOrd, Ord` for `FontName`.
+* Add `zng_view::platform`.
 * Implemented Android `run_same_process` entry point.
 * Fixed Android build errors.
 * Fix gradient stops that mix positional stops with offset stops. 
diff --git a/crates/cargo-zng/README.md b/crates/cargo-zng/README.md
index bd81fd75a..0f6c0bd8b 100644
--- a/crates/cargo-zng/README.md
+++ b/crates/cargo-zng/README.md
@@ -481,7 +481,7 @@ $ cargo zng res --tools
 .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
+call 'cargo zng res --tool tool' to read full help from a tool
 ```
 
 The expanded help for each:
diff --git a/crates/cargo-zng/src/res/built_in.rs b/crates/cargo-zng/src/res/built_in.rs
index 1d3e8f13d..4fc3b15de 100644
--- a/crates/cargo-zng/src/res/built_in.rs
+++ b/crates/cargo-zng/src/res/built_in.rs
@@ -212,6 +212,8 @@ fn glob() {
         s
     };
 
+    let mut any = false;
+
     'apply: for source in selection {
         if source.is_dir() {
             let filters_root = source.parent().map(Path::to_owned).unwrap_or_default();
@@ -227,6 +229,7 @@ fn glob() {
                 }
                 let target = target.join(match_source);
 
+                any = true;
                 if source.is_dir() {
                     fs::create_dir_all(&target).unwrap_or_else(|e| fatal!("cannot create dir `{}`, {e}", source.display()));
                 } else {
@@ -248,12 +251,17 @@ fn glob() {
             }
             let target = target.join(source_name.as_ref());
 
+            any = true;
             fs::copy(&source, &target).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", source.display(), target.display()));
             println!("{}", display_path(&target));
         } else if source.is_symlink() {
             symlink_warn(&source);
         }
     }
+
+    if !any {
+        println!("no match")
+    }
 }
 
 const RP_HELP: &str = "
@@ -672,7 +680,11 @@ The expected folder structure:
 | ├── lib/
 | |   └── arm64-v8a
 | |       └── my-app.so
+| ├── assets/
+| |   └── res
+| |       └── zng-res.txt
 | ├── res/
+| |   └── android-res
 | ├── AndroidManifest.xml
 | └── build.zr-apk
 
@@ -680,14 +692,17 @@ 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
+| # 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
+|
+| # 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
+|
+| # Don't tar assets. By default `assets/res` are packed as `assets/res.tar`
+| # for use with `android_install_res`.
+| tar-assets-res = false
 
 APK signing is configured using these environment variables:
 
@@ -706,6 +721,7 @@ fn apk() {
     // read config
     let mut debug = false;
     let mut raw = false;
+    let mut tar_assets = true;
     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('=') {
@@ -723,6 +739,7 @@ fn apk() {
             match key {
                 "debug" => debug = bool_value(),
                 "raw" => raw = bool_value(),
+                "tar-assets" => tar_assets = bool_value(),
                 _ => error!("unknown key, line {ln}\n   {line}"),
             }
         } else {
@@ -783,12 +800,36 @@ fn apk() {
     let _ = fs::remove_dir_all(&temp_dir);
     fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
 
+    // tar assets
+    let assets = apk_folder.join("assets");
+    let assets_res = assets.join("res");
+    if tar_assets && assets_res.exists() {
+        let tar_path = assets.join("res.tar");
+        let r = Command::new("tar")
+            .arg("-cf")
+            .arg(&tar_path)
+            .arg("res")
+            .current_dir(&assets)
+            .status();
+        match r {
+            Ok(s) => {
+                if !s.success() {
+                    fatal!("tar failed")
+                }
+            }
+            Err(e) => fatal!("cannot run 'tar', {e}"),
+        }
+        if let Err(e) = fs::remove_dir_all(&assets_res) {
+            fatal!("failed tar-assets-res cleanup, {e}")
+        }
+    }
+
     // 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"));
+        aapt2.arg("compile").arg("-o").arg(&compiled_res).arg("--dir").arg(res);
 
         if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
             fatal!("resources build failed");
@@ -839,6 +880,9 @@ fn apk() {
     if compiled_res.exists() {
         aapt2.arg(&compiled_res);
     }
+    if assets.exists() {
+        aapt2.arg("-A").arg(&assets);
+    }
     if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
         fatal!("apk linking failed");
     }
diff --git a/crates/zng-env/Cargo.toml b/crates/zng-env/Cargo.toml
index 45227982d..2f4b58444 100644
--- a/crates/zng-env/Cargo.toml
+++ b/crates/zng-env/Cargo.toml
@@ -14,7 +14,7 @@ keywords = ["gui", "ui", "user-interface", "zng"]
 [features]
 # Check if `res` path is available in `init_built_res` first.
 #
-# Enabled by default in debug builds.
+# Enabled by default in debug builds, ignored in Android and Wasm.
 built_res = []
 
 [dependencies]
@@ -29,6 +29,9 @@ semver = { version = "1.0", features = ["serde"] }
 zng-unique-id = { path = "../zng-unique-id", version = "0.4.4" }
 zng-txt = { path = "../zng-txt", version = "0.2.7" }
 
+[target.'cfg(target_os = "android")'.dependencies]
+tar = "0.4"
+
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 # `on_process_start!` uses pre-expanded code generated by linkme macro that must match link_section name.
 linkme = "=0.3.28"
diff --git a/crates/zng-env/src/lib.rs b/crates/zng-env/src/lib.rs
index 996183de3..3a0fdcb3b 100644
--- a/crates/zng-env/src/lib.rs
+++ b/crates/zng-env/src/lib.rs
@@ -75,7 +75,27 @@ lazy_static! {
 ///
 /// The example above imports and runs an app built using [`wasm-pack`] with `--target web` options.
 ///
+/// # Android Start
+///
+/// Android builds (`target_os = "android"`) receive an `AndroidApp` instance from the `android_main`. This type
+/// is tightly coupled with the view-process implementation and so it is defined by the `zng-view` crate. In builds
+/// feature `"view"` you must call `zng::view_process::default::android::init_android_app` just after `init!`.
+///
+/// ```
+/// # macro_rules! demo { () => {
+/// #[no_mangle]
+/// fn android_main(app: zng::view_process::default::android::AndroidApp) {
+///     zng::env::init!();
+///     zng::view_process::default::android::init_android_app(app);
+///     // zng::view_process::default::run_same_process(..);
+/// }
+/// # }}
+/// ```
+///
+/// See the [multi example] for more details on how to support Android and other platforms.
+///
 /// [`wasm-pack`]: https://crates.io/crates/wasm-pack
+/// [multi example]: https://github.com/zng-ui/zng/tree/main/examples#multi
 #[macro_export]
 macro_rules! init {
     () => {
@@ -262,7 +282,7 @@ fn fallback_name() -> Txt {
 
 /// Gets a path relative to the package binaries.
 ///
-/// * In `cfg(target_arch = "wasm32")` returns `./`, as in the relative URL.
+/// * In Wasm returns `./`, as in the relative URL.
 /// * In all other platforms returns `std::env::current_exe().parent()`.
 ///
 /// # Panics
@@ -286,13 +306,12 @@ fn find_bin() -> PathBuf {
 /// Gets a path relative to the package resources.
 ///
 /// * The res dir can be set by [`init_res`] before any env dir is used.
-/// * In all platforms expect Wasm if a file `bin/current_exe_name.res-dir` is found the first non-empty and non-comment (#) line
+/// * In Android returns `android_internal("res")`, assumes the package assets are extracted to this directory.
+/// * In Linux, macOS and Windows if a file `bin/current_exe_name.res-dir` is found the first non-empty and non-comment (#) line
 ///   defines the res path.
 /// * In `cfg(debug_assertions)` builds returns `res`.
-/// * In `cfg(target_arch = "wasm32")` returns `./res`, as in the relative URL.
+/// * In Wasm returns `./res`, as in the relative URL.
 /// * In macOS returns `bin("../Resources")`, assumes the package is deployed using a desktop `.app` folder.
-/// * In iOS returns `bin("")`, assumes the package is deployed as a mobile `.app` folder.
-/// * In Android returns `bin("../res/raw")`, assumes the package is deployed as a `.apk` file.
 /// * In all other Unix systems returns `bin("../share/current_exe_name")`, assumes the package is deployed
 ///   using a Debian package.
 /// * In Windows returns `bin("../res")`. Note that there is no Windows standard, make sure to install
@@ -305,10 +324,19 @@ fn find_bin() -> PathBuf {
 /// included in version control.
 ///
 /// Note that the built resources must be packaged with the other res at the same relative location, so that release builds can find them.
+///
+/// # Android
+///
+/// Unfortunately Android does not provide file system access to the bundled resources, you must use the `ndk::asset::AssetManager` to
+/// request files that are decompressed on demand from the APK file. We recommend extracting all cross-platform assets once on startup
+/// to avoid having to implement special Android handling for each resource usage. See [`android_install_res`] for more details.
 pub fn res(relative_path: impl AsRef<Path>) -> PathBuf {
     res_impl(relative_path.as_ref())
 }
-#[cfg(any(debug_assertions, feature = "built_res"))]
+#[cfg(all(
+    any(debug_assertions, feature = "built_res"),
+    not(any(target_os = "android", target_arch = "wasm32")),
+))]
 fn res_impl(relative_path: &Path) -> PathBuf {
     let built = BUILT_RES.join(relative_path);
     if built.exists() {
@@ -317,11 +345,82 @@ fn res_impl(relative_path: &Path) -> PathBuf {
 
     RES.join(relative_path)
 }
-#[cfg(not(any(debug_assertions, feature = "built_res")))]
+#[cfg(not(all(
+    any(debug_assertions, feature = "built_res"),
+    not(any(target_os = "android", target_arch = "wasm32")),
+)))]
 fn res_impl(relative_path: &Path) -> PathBuf {
     RES.join(relative_path)
 }
 
+/// Helper function for adapting Android assets to the cross-platform [`res`] API.
+///
+/// To implement Android resource extraction, bundle the resources in a tar that is itself bundled in `assets/res.tar` inside the APK.
+/// On startup, call this function, it handles resources extraction and versioning.
+///
+/// # Examples
+///
+/// ```
+/// # macro_rules! demo { () => {
+/// #[no_mangle]
+/// fn android_main(app: zng::view_process::default::android::AndroidApp) {
+///     zng::env::init!();
+///     zng::view_process::default::android::init_android_app(app.clone());
+///     zng::env::android_install_res(|| app.asset_manager().open(c"res.tar"));
+///     // zng::view_process::default::run_same_process(..);
+/// }
+/// # }}
+/// ```
+///
+/// The `open_res` closure is only called if this is the first instance of the current app version on the device, or if the user
+/// cleared all app data.
+///
+/// The resources are installed in the [`res`] directory, if the tar archive has only a root dir named `res` it is stripped.
+/// This function assumes that it is the only app component that writes to this directory.
+///
+/// Note that the tar file is not compressed, because the APK already compresses it. The `cargo zng res` tool `.zr-apk`
+/// tar resources by default, simply place the resources in `/assets/res/`.
+pub fn android_install_res<Asset: std::io::Read>(open_res: impl FnOnce() -> Option<Asset>) {
+    #[cfg(target_os = "android")]
+    {
+        let version = res(format!(".zng-env.res.{}", about().version));
+        if !version.exists() {
+            if let Some(res) = open_res() {
+                if let Err(e) = install_res(version, res) {
+                    tracing::error!("res install failed, {e}");
+                }
+            }
+        }
+    }
+    // cfg not applied to function so it shows on docs
+    #[cfg(not(target_os = "android"))]
+    let _ = open_res;
+}
+#[cfg(target_os = "android")]
+fn install_res(version: PathBuf, res: impl std::io::Read) -> std::io::Result<()> {
+    let res_path = version.parent().unwrap();
+    let _ = fs::remove_dir_all(res_path);
+    fs::create_dir(res_path)?;
+
+    let mut res = tar::Archive::new(res);
+    res.unpack(res_path)?;
+
+    // rename res/res to res if it is the only entry in res
+    let mut needs_pop = false;
+    for (i, entry) in fs::read_dir(&res_path)?.take(2).enumerate() {
+        needs_pop = i == 0 && entry?.file_name() == "res";
+    }
+    if needs_pop {
+        let tmp = res_path.parent().unwrap().join("res-tmp");
+        fs::rename(res_path.join("res"), &tmp)?;
+        fs::rename(tmp, res_path)?;
+    }
+
+    fs::File::create(&version)?;
+
+    Ok(())
+}
+
 /// Sets a custom [`res`] path.
 ///
 /// # Panics
@@ -351,6 +450,11 @@ lazy_static! {
     #[cfg(any(debug_assertions, feature="built_res"))]
     static ref BUILT_RES: PathBuf = PathBuf::from("target/res");
 }
+#[cfg(target_os = "android")]
+fn find_res() -> PathBuf {
+    android_internal("res")
+}
+#[cfg(not(target_os = "android"))]
 fn find_res() -> PathBuf {
     #[cfg(not(target_arch = "wasm32"))]
     if let Ok(mut p) = std::env::current_exe() {
@@ -367,10 +471,6 @@ fn find_res() -> PathBuf {
         bin("../res")
     } else if cfg!(target_os = "macos") {
         bin("../Resources")
-    } else if cfg!(target_os = "ios") {
-        bin("")
-    } else if cfg!(target_os = "android") {
-        bin("res/raw")
     } else if cfg!(target_family = "unix") {
         let c = current_exe();
         bin(format!("../share/{}", c.file_name().unwrap().to_string_lossy()))
@@ -385,7 +485,8 @@ fn find_res() -> PathBuf {
 /// Gets a path relative to the user config directory for the app.
 ///
 /// * The config dir can be set by [`init_config`] before any env dir is used.
-/// * In all platforms except Wasm if a file in `res("config-dir")` is found the first non-empty and non-comment (#) line
+/// * In Android returns `android_internal("config")`.
+/// * In Linux, macOS and Windows if a file in `res("config-dir")` is found the first non-empty and non-comment (#) line
 ///   defines the res path.
 /// * In `cfg(debug_assertions)` builds returns `target/tmp/dev_config/`.
 /// * In all platforms attempts [`directories::ProjectDirs::config_dir`] and panic if it fails.
@@ -497,6 +598,11 @@ lazy_static! {
     static ref CONFIG: PathBuf = redirect_config(original_config());
 }
 
+#[cfg(target_os = "android")]
+fn find_config() -> PathBuf {
+    android_internal("config")
+}
+#[cfg(not(target_os = "android"))]
 fn find_config() -> PathBuf {
     let cfg_dir = res("config-dir");
     if let Ok(dir) = read_line(&cfg_dir) {
@@ -560,7 +666,8 @@ fn create_dir_opt(dir: PathBuf) -> PathBuf {
 /// Gets a path relative to the cache directory for the app.
 ///
 /// * The cache dir can be set by [`init_cache`] before any env dir is used.
-/// * In all platforms except Wasm if a file `config("cache-dir")` is found the first non-empty and non-comment (#) line
+/// * In Android returns `android_internal("cache")`.
+/// * In Linux, macOS and Windows if a file `config("cache-dir")` is found the first non-empty and non-comment (#) line
 ///   defines the res path.
 /// * In `cfg(debug_assertions)` builds returns `target/tmp/dev_cache/`.
 /// * In all platforms attempts [`directories::ProjectDirs::cache_dir`] and panic if it fails.
@@ -743,6 +850,11 @@ fn best_effort_move(from: &Path, to: &Path) -> io::Result<()> {
 lazy_static! {
     static ref CACHE: PathBuf = create_dir_opt(find_cache());
 }
+#[cfg(target_os = "android")]
+fn find_cache() -> PathBuf {
+    android_internal("cache")
+}
+#[cfg(not(target_os = "android"))]
 fn find_cache() -> PathBuf {
     let cache_dir = config("cache-dir");
     if let Ok(dir) = read_line(&cache_dir) {
@@ -782,6 +894,40 @@ fn read_line(path: &Path) -> io::Result<String> {
     Err(io::Error::new(io::ErrorKind::UnexpectedEof, "no uncommented line"))
 }
 
+#[cfg(target_os = "android")]
+mod android {
+    use super::*;
+
+    lazy_static! {
+        static ref ANDROID_PATHS: [PathBuf; 2] = [PathBuf::new(), PathBuf::new()];
+    }
+
+    /// Initialize the Android app paths.
+    ///
+    /// This is called by `init_android_app` provided by view-process implementers.
+    pub fn init_android_paths(internal: PathBuf, external: PathBuf) {
+        if lazy_static_init(&ANDROID_PATHS, [internal, external]).is_err() {
+            panic!("cannot `init_android_paths`, already inited")
+        }
+    }
+
+    /// Gets a path relative to the internal storage reserved for the app.
+    ///
+    /// Prefer using [`config`] or [`cache`] over this directly.
+    pub fn android_internal(relative_path: impl AsRef<Path>) -> PathBuf {
+        ANDROID_PATHS[0].join(relative_path)
+    }
+
+    /// Gets a path relative to the external storage reserved for the app.
+    ///
+    /// This directory is user accessible.
+    pub fn android_external(relative_path: impl AsRef<Path>) -> PathBuf {
+        ANDROID_PATHS[1].join(relative_path)
+    }
+}
+#[cfg(target_os = "android")]
+pub use android::*;
+
 #[cfg(test)]
 mod tests {
     use crate::*;
diff --git a/crates/zng-view/Cargo.toml b/crates/zng-view/Cargo.toml
index 7e9765725..2e510ad54 100644
--- a/crates/zng-view/Cargo.toml
+++ b/crates/zng-view/Cargo.toml
@@ -155,6 +155,9 @@ features = ["NSEvent", "NSAppearance", "NSColor", "NSColorSpace"]
 version = "0.2.2"
 features = ["NSString"]
 
+[target.'cfg(target_os = "android")'.dependencies]
+parking_lot = "0.12"
+
 [build-dependencies]
 zng-tp-licenses = { path = "../zng-tp-licenses", version = "0.2.7", features = [
     "build",
diff --git a/crates/zng-view/src/lib.rs b/crates/zng-view/src/lib.rs
index f46748a8d..b2e53227a 100644
--- a/crates/zng-view/src/lib.rs
+++ b/crates/zng-view/src/lib.rs
@@ -224,26 +224,13 @@ pub extern "C" fn extern_view_process_main() {
 ///
 /// # 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,
-    )
+/// In Android builds this function requires `android::init_android_app` must be called before this call.
+pub fn run_same_process(run_app: impl FnOnce() + Send + 'static) {
+    run_same_process_extended(run_app, ViewExtensions::new)
 }
 
 /// Like [`run_same_process`] but with custom API extensions.
-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,
-) {
+pub fn run_same_process_extended(run_app: impl FnOnce() + Send + 'static, ext: fn() -> ViewExtensions) {
     let app_thread = thread::Builder::new()
         .name("app".to_owned())
         .spawn(move || {
@@ -270,19 +257,9 @@ pub fn run_same_process_extended(
     let c = ipc::connect_view_process(config.server_name).expect("failed to connect to app in same process");
 
     if config.headless {
-        App::run_headless(
-            #[cfg(target_os = "android")]
-            android_app,
-            c,
-            ext(),
-        );
+        App::run_headless(c, ext());
     } else {
-        App::run_headed(
-            #[cfg(target_os = "android")]
-            android_app,
-            c,
-            ext(),
-        );
+        App::run_headed(c, ext());
     }
 
     if let Err(p) = app_thread.join() {
@@ -1054,11 +1031,7 @@ impl App {
         }
     }
 
-    pub fn run_headless(
-        #[cfg(target_os = "android")] android_app: platform::android::activity::AndroidApp,
-        ipc: ipc::ViewChannels,
-        ext: ViewExtensions,
-    ) {
+    pub fn run_headless(ipc: ipc::ViewChannels, ext: ViewExtensions) {
         tracing::info!("running headless view-process");
 
         gl::warmup();
@@ -1078,7 +1051,10 @@ impl App {
         #[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();
+        let event_loop = EventLoop::builder()
+            .with_android_app(platform::android::android_app())
+            .build()
+            .unwrap();
         drop(winit_span);
 
         let mut app = HeadlessApp {
@@ -1185,11 +1161,7 @@ impl App {
         }
     }
 
-    pub fn run_headed(
-        #[cfg(target_os = "android")] android_app: platform::android::activity::AndroidApp,
-        ipc: ipc::ViewChannels,
-        ext: ViewExtensions,
-    ) {
+    pub fn run_headed(ipc: ipc::ViewChannels, ext: ViewExtensions) {
         tracing::info!("running headed view-process");
 
         gl::warmup();
@@ -1198,7 +1170,10 @@ impl App {
         #[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();
+        let event_loop = EventLoop::with_user_event()
+            .with_android_app(platform::android::android_app())
+            .build()
+            .unwrap();
         drop(winit_span);
         let app_sender = event_loop.create_proxy();
 
diff --git a/crates/zng-view/src/platform.rs b/crates/zng-view/src/platform.rs
index 7aae1db1a..0a4b2a9c7 100644
--- a/crates/zng-view/src/platform.rs
+++ b/crates/zng-view/src/platform.rs
@@ -7,4 +7,29 @@
 #[cfg(target_os = "android")]
 pub mod android {
     pub use winit::platform::android::activity;
+
+    #[cfg(target_os = "android")]
+    static ANDROID_APP: parking_lot::RwLock<Option<activity::AndroidApp>> = parking_lot::RwLock::new(None);
+
+    /// Sets the [`android_app`] instance for this process and the Android paths.
+    ///
+    /// This must be called just after `zng::env::init!` and before `run_same_process*`.
+    #[cfg(target_os = "android")]
+    pub fn init_android_app(app: activity::AndroidApp) {
+        let internal = app.internal_data_path().unwrap_or_default();
+        let external = app.external_data_path().unwrap_or_default();
+        zng_env::init_android_paths(internal, external);
+        *ANDROID_APP.write() = Some(app);
+    }
+
+    /// Gets the `AndroidApp` instance for this process.
+    ///
+    /// Panics if called before [`init_android_app`].
+    #[cfg(target_os = "android")]
+    pub fn android_app() -> activity::AndroidApp {
+        ANDROID_APP
+            .read()
+            .clone()
+            .expect("android_app is only available after `init_android_app` call")
+    }
 }
diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml
index df1c53ea9..f7f622f27 100644
--- a/crates/zng/Cargo.toml
+++ b/crates/zng/Cargo.toml
@@ -179,7 +179,7 @@ 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.
 #
-# Enabled by default in debug builds.
+# Enabled by default in debug builds, ignored in Android and Wasm.
 built_res = ["zng-env/built_res"]
 
 # Standard Android backend that requires a build system that can compile Java or Kotlin and fetch Android dependencies.
diff --git a/crates/zng/src/env.rs b/crates/zng/src/env.rs
index 951e31dd9..97c3e8643 100644
--- a/crates/zng/src/env.rs
+++ b/crates/zng/src/env.rs
@@ -28,7 +28,7 @@
 //! Note that init **must be called in main**, it must be called in main to define the lifetime of the processes,
 //! this is needed to properly call [`on_process_exit`] handlers.
 //!
-//! Also see the [`init!`] section on WebAssembly for details on how to init in `"wasm32"` target builds.
+//! Also see [`init!`] docs for details init in `"wasm32"` and `"android"` target builds.
 //!
 //! # Full API
 //!
@@ -39,5 +39,8 @@ pub use zng_env::{
     on_process_start, res, About, ProcessExitArgs, ProcessStartArgs,
 };
 
+#[cfg(target_os = "android")]
+pub use zng_env::{android_external, android_install_res};
+
 #[cfg(any(debug_assertions, feature = "built_res"))]
 pub use zng_env::init_built_res;
diff --git a/crates/zng/src/view_process.rs b/crates/zng/src/view_process.rs
index 81d452739..e666d92eb 100644
--- a/crates/zng/src/view_process.rs
+++ b/crates/zng/src/view_process.rs
@@ -8,12 +8,12 @@
 //! // use zng::view_process::prebuilt as view_process;
 //!
 //! fn main() {
+//!     zng::env::init!();
 //!     // single_process();
 //!     // multi_process();
 //! }
 //!
 //! fn multi_process() {
-//!     zng::env::init!();
 //!     app();
 //! }
 //!
@@ -30,6 +30,8 @@
 //! ```
 //!
 //! See the [`app`](crate::app) module documentation for more details about view-processes.
+//!
+//! See [`zng::env::init!`] for more details about running Android apps.
 
 /// Default view-process implementation.
 ///
@@ -40,17 +42,19 @@
 pub mod default {
     pub use zng_view::run_same_process;
 
-    /// Android backend required types.
+    /// Android init types.
     ///
     /// See [`winit::platform::android`](https://docs.rs/winit/latest/winit/platform/android/) for more details
     /// on how to select a backend "Activity".
     ///
+    /// See [`zng::env::init!`] for more details about running Android apps.
+    ///
     /// # 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;
+        pub use zng_view::platform::android::{activity::AndroidApp, init_android_app};
     }
 }
 
diff --git a/examples/multi/Cargo.toml b/examples/multi/Cargo.toml
index e35ff1dbf..82aa70185 100644
--- a/examples/multi/Cargo.toml
+++ b/examples/multi/Cargo.toml
@@ -5,17 +5,17 @@ publish = false
 edition = "2021"
 
 [lib]
+# required by Android and Wasm builds
 crate-type = ["cdylib"]
 
 [dependencies]
+# feature "view" si required to support Android and Wasm as the prebuilt view-process only supports desktop platforms.
+# feature "android_native_activity" selects the more basic Android backend that does not require any Java to build.
 zng = { path = "../../crates/zng", features = ["view", "android_native_activity", "material_icons"] }
 tracing = "0.1"
 
-zng-env = { path = "../../crates/zng-env" }
-
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 wasm-bindgen = "0.2"
-
 [target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
 version = "0.3.4"
 features = [
diff --git a/examples/multi/res/my-res.txt b/examples/multi/res/my-res.txt
new file mode 100644
index 000000000..f184c63c0
--- /dev/null
+++ b/examples/multi/res/my-res.txt
@@ -0,0 +1,1 @@
+Resource content!
\ No newline at end of file
diff --git a/examples/multi/src/app.rs b/examples/multi/src/app.rs
index 7df314060..7c8ba3807 100644
--- a/examples/multi/src/app.rs
+++ b/examples/multi/src/app.rs
@@ -2,34 +2,44 @@ use zng::prelude::*;
 
 #[allow(unused)]
 pub fn run() {
-    APP.defaults().run_window(async {
-        let count = var(0u32);
+    let app = APP.defaults();
+    CONFIG.load(zng::config::JsonConfig::sync(zng::env::config("config.json")));
+
+    let res = zng::env::res("my-res.txt");
+    match std::fs::read_to_string(&res) {
+        Ok(res) => tracing::info!("resource read ok: {res}"),
+        Err(e) => tracing::error!("resource read error, cannot read '{}', {e}", res.display()),
+    }
+
+    app.run_window(async {
+        let count = CONFIG.get("count", 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 = count.map(|&c| formatx!("HELLO {}!\n\n{c}", std::env::consts::OS.to_uppercase()));
                     txt_align = Align::CENTER;
-                    layout::padding = 20;
+                    layout::padding = 40;
                     font_family = "fantasy";
                     font_size = 2.em();
                     font_weight = FontWeight::BOLD;
                     font_style = FontStyle::Italic;
 
                 };
-                on_click = hn!(|_| {
-                    let c = count.get() + 1;
+                on_click = hn!(count, |_| {
+                    let c = count.get().wrapping_add(1);
                     tracing::info!("Clicked {c} times!");
                     count.set(c);
                 });
+                context_menu = ContextMenu!(ui_vec![
+                    Button! {
+                        child = Text!("Reset");
+                        on_click = hn!(|_| {
+                            count.set(0u32);
+                        });
+                    }
+                ]);
             };
             color_scheme = color::ColorScheme::Dark;
             widget::background_gradient = {
diff --git a/examples/multi/src/lib.rs b/examples/multi/src/lib.rs
index 837ce7e36..5f98f4106 100644
--- a/examples/multi/src/lib.rs
+++ b/examples/multi/src/lib.rs
@@ -8,6 +8,8 @@ mod wasm {
 
     #[wasm_bindgen(start)]
     fn main() {
+        // Wasm requires a Javascript attribute in the "window" object, see the
+        // zng::env::init! docs for more details.
         zng::env::init!();
         app::run_headless();
 
@@ -36,17 +38,19 @@ mod wasm {
 #[cfg(target_os = "android")]
 mod android {
     use super::app;
+    use zng::view_process::default::*;
 
     #[no_mangle]
-    fn android_main(app: zng::view_process::default::android::AndroidApp) {
+    fn android_main(app: android::AndroidApp) {
         zng::env::init!();
+
         zng::app::print_tracing(tracing::Level::INFO);
+        tracing::info!("Hello Android!");
 
-        if let Some(p) = app.internal_data_path() {
-            zng::env::init_config(p);
-        }
+        android::init_android_app(app.clone());
+        // ./res packed by `cargo do build-apk` using `cargo zng res --pack`
+        zng::env::android_install_res(|| app.asset_manager().open(c"res.tar"));
 
-        tracing::info!("Hello Android!");
-        zng::view_process::default::run_same_process(app, app::run);
+        run_same_process(app::run);
     }
 }
diff --git a/examples/multi/src/main.rs b/examples/multi/src/main.rs
index fa1f98b20..63143579c 100644
--- a/examples/multi/src/main.rs
+++ b/examples/multi/src/main.rs
@@ -12,6 +12,9 @@
 mod app;
 
 fn main() {
+    // usually resources are packed to a default dir using `cargo zng res --pack`
+    zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
+
     zng::env::init!();
     app::run();
 }
diff --git a/tools/cargo-do/src/main.rs b/tools/cargo-do/src/main.rs
index ab75efdfa..431aaf174 100644
--- a/tools/cargo-do/src/main.rs
+++ b/tools/cargo-do/src/main.rs
@@ -921,6 +921,18 @@ fn build_apk(mut args: Vec<&str>) {
     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 example_res = std::path::Path::new("examples").join(&e).join("res");
+    if example_res.exists() {
+        let apk_assets = apk_dir.join("assets/");
+        let _ = std::fs::create_dir_all(&apk_assets);
+        let apk_assets = apk_assets.join("copy.zr-glob");
+        std::fs::write(
+            apk_assets,
+            format!("{}\n!:*/screenshot.png", dunce::canonicalize(example_res).unwrap().display()).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);


Test patch: