# MeshBay — Android client A client, not a host: no node runs on a phone (`docs/MESHBAY_DESIGN.md` §11.3). The shell is a system WebView showing the interface **from the package** — `meshbay-hub/src/meshbay_hub/static/` copied at build time into `build/generated/`, never committed (§8.3) — with a bridge (`app/src/main/assets/bridge/meshbay-bridge.js`) that offers the page the same `window.meshbay` as the desktop preload, wherever it offers anything at all. Hub calls leave from native code, to the signed-in hub only. The device key, the bundle key and every node identity are held natively under an Android Keystore key; the page is told public keys and handed signatures, asked for by kind — never bytes. `meshbay-hub/tests/vectors/keyring.json` holds that keyring to the desktop's and to the specification. Downloads are written to disk as they arrive — into the folder chosen in Settings (a Storage Access Framework tree, as `.part` until complete) or the system Downloads collection (a pending entry until complete) — and the page holds an opaque id, never a URI. Uploads come through the system picker. Casting: the page pushes the decrypted stream to a local HTTP relay (a port of the desktop's `cast-relay.js`, bound to the Wi-Fi address only); receivers are found and driven through the platform cast SDK with the default media receiver. While a cast runs, a media-playback foreground service holds the CPU and the Wi-Fi, and the WebView is kept reported visible — without that, Chromium freezes the page 60 s after the screen goes off. Where play services are absent, the page is offered no cast at all. Music playing on the phone itself holds the same service and the same visibility, for as long as it plays (`playback:keep-alive`), so the next track still loads with the screen off. ```bash # needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties) ./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk ./gradlew testDebugUnitTest # JVM unit tests ./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk ``` A release is signed with the release key, which never enters the repository. `assembleRelease` reads it from `~/.gradle/gradle.properties`, and stops if any of these is missing rather than signing with the debug key: ```properties meshbayReleaseStoreFile=/path/to/meshbay-release.jks meshbayReleaseStorePassword=... meshbayReleaseKeyAlias=meshbay meshbayReleaseKeyPassword=... ``` `apksigner verify --print-certs app-release.apk` prints the certificate's SHA-256 fingerprint, the one the download page publishes (§8.2). A release does not install over a debug build, or the reverse: the keys differ. The security contract is also pinned from the Python suite by reading this source: `packages/meshbay-hub/tests/test_android_shell.py`. Notifications while closed, with nothing to install: `notify/PollJob` (a system job, no library) fetches what is new from the hub every fifteen minutes with the phone's poll secret — never a session. When a UnifiedPush distributor is already on the phone (ntfy, …) it is used too: the hub pushes at once, encrypted to the phone (RFC 8291), `notify/PushReceiver` draws what the connector could decrypt, and the fetch slows to a four-hour net. The page turns it on in Settings and registers the phone with the hub; muting and "disable all" are decided on the hub, which then creates nothing (§11.3). Not built yet: phone-specific behaviour (back button, network handover, keeping a download alive with the screen off), updates through a store. ## Icon The desktop client's icon, `meshbay-client/build/icon-square.png`, placed in the adaptive icon's safe zone (72 dp of the 108 dp layer, which is what every launcher mask leaves visible) over its own edge colour, so no mask crops the M. The five `mipmap-*/ic_launcher_foreground.png` are generated from it: ```python from PIL import Image, ImageDraw, ImageFilter src = Image.open("../meshbay-client/build/icon-square.png").convert("RGBA") for name, L in [("mdpi", 108), ("hdpi", 162), ("xhdpi", 216), ("xxhdpi", 324), ("xxxhdpi", 432)]: S = L * 72 // 108; b = max(2, S // 25) img = src.resize((S, S), Image.LANCZOS) mask = Image.new("L", (S, S), 0) ImageDraw.Draw(mask).rectangle([b, b, S - b - 1, S - b - 1], fill=255) img.putalpha(mask.filter(ImageFilter.GaussianBlur(b))) layer = Image.new("RGBA", (L, L), (0, 0, 0, 0)) layer.paste(img, ((L - S) // 2, (L - S) // 2), img) layer.save(f"app/src/main/res/mipmap-{name}/ic_launcher_foreground.png", optimize=True) ``` No monochrome layer: a themed icon keeps only the layer's alpha, and this one would be a filled square.