diff --git a/appstores/android/.gitignore b/appstores/android/.gitignore
new file mode 100644
index 00000000..d544a517
--- /dev/null
+++ b/appstores/android/.gitignore
@@ -0,0 +1,5 @@
+.gradle/
+app/build/
+build/
+dist/
+local.properties
diff --git a/appstores/android/README.md b/appstores/android/README.md
index 3c5fa4b4..b78e10ea 100644
--- a/appstores/android/README.md
+++ b/appstores/android/README.md
@@ -1,20 +1,47 @@
# Google Play (pairux.com)
-Not built yet.
-
-**What it takes:** an Android app (the Fire TV shell in `../firetv` is already a
-plain Android app and is the obvious starting point — same WebView, same
-`tv.js`, minus the leanback intent filter and plus a phone layout), a Play
-Console developer account, an upload key, and a Play App Signing enrolment.
-
-**Before the first upload**
-
-- Play requires an **App Bundle** (`.aab`), not an APK, and a `targetSdk` that
- meets Play's current floor. The Fire TV build targets 34 for Fire OS; Play
- moves its floor every August, so check it rather than assuming.
-- `applicationId` must differ from nothing else on Play, and once published it
- can never change.
-- Play wants a privacy policy URL, a Data safety form, and content rating
- answers — the same facts as the Amazon listing, asked differently.
-- The listing copy and art in `../firetv/store` mostly carries over; Play's
- screenshot sizes and the 1024x500 feature graphic do not.
+A WebView shell for https://pairux.com. Package `com.pairux.app`, minSdk 24, targetSdk 35.
+
+## Build
+
+```sh
+docker run --rm -e OWNER=$(id -u):$(id -g) \
+ -v "$PWD/android-sdk:/sdk" -v "$PWD/keys:/keys" \
+ -v "$PWD/appstores/android:/w" -w /w gradle:8.7-jdk17 sh build.sh
+```
+
+`dist/pairux.aab` is what Play takes; `dist/pairux.apk` is for sideloading and testing.
+Art: `docker run --rm -v "$PWD/appstores/android:/w" -w /w alpine:3.20 sh store/render.sh`.
+
+## Signing
+
+The upload key is in vault `profullstack/pairux-android--signing`
+(`ANDROID_KEYSTORE_B64`, `ANDROID_STORE_PASSWORD`, `ANDROID_KEY_ALIAS`).
+
+This is the **upload** key, not the app signing key. Once enrolled in Play App
+Signing, Google holds the signing key and re-signs every upload, so a lost upload
+key can be reset by support; a lost Play-managed signing key cannot. `new-key.sh`
+refuses to mint a second one.
+
+## What it adds over the website
+
+Play rejects a bare web wrapper. This one is a launcher entry plus: `pairux.com` links
+opening in the app (`autoVerify` App Links), the system Back button walking the
+page's own history, `` working (a WebView does nothing on file
+inputs without `onShowFileChooser`), `getUserMedia` bridged to Android's own
+permission prompt, full-screen video, and an offline screen instead of Chrome's.
+
+## Before the first release
+
+- Play Console account: **anthony@profullstack.com**.
+- Play takes an **.aab**, never an APK, and raises its `targetSdk` floor every
+ August — check it rather than trusting the 35 here.
+- `applicationId` can never change after publishing.
+- The Data safety form and content rating questionnaire are required, and ask the
+ same facts as Amazon's listing in a different shape.
+- A new personal Play account needs 12 testers on a closed test for 14 days before
+ production opens. An organisation account needs D-U-N-S verification instead.
+
+## Listing
+
+Copy and art live in `store/` — see `store/listing.md`.
diff --git a/appstores/android/app/build.gradle b/appstores/android/app/build.gradle
new file mode 100644
index 00000000..43a0fbcb
--- /dev/null
+++ b/appstores/android/app/build.gradle
@@ -0,0 +1,46 @@
+plugins { id 'com.android.application' }
+
+def env = System.getenv()
+
+android {
+ namespace 'com.pairux.app'
+ compileSdk 35
+
+ defaultConfig {
+ applicationId 'com.pairux.app'
+ minSdk 24 // Android 7: what Play still sees in the wild
+ targetSdk 35 // Play raises its floor every August; check before a release
+ versionCode 1
+ versionName '1.0.0'
+ }
+
+ // The upload key lives in the vault, never in the repo. build.sh writes it to a
+ // temp file and points these at it. Play re-signs with its own app signing key.
+ signingConfigs {
+ release {
+ if (env.ANDROID_KEYSTORE) {
+ storeFile file(env.ANDROID_KEYSTORE)
+ storePassword env.ANDROID_STORE_PASSWORD
+ keyAlias env.ANDROID_KEY_ALIAS ?: 'pairux'
+ keyPassword env.ANDROID_KEY_PASSWORD ?: env.ANDROID_STORE_PASSWORD
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ if (env.ANDROID_KEYSTORE) signingConfig signingConfigs.release
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ lint {
+ abortOnError true
+ checkReleaseBuilds true
+ }
+}
diff --git a/appstores/android/app/src/main/AndroidManifest.xml b/appstores/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..1231d72f
--- /dev/null
+++ b/appstores/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/appstores/android/app/src/main/assets/offline.html b/appstores/android/app/src/main/assets/offline.html
new file mode 100644
index 00000000..a6c125bc
--- /dev/null
+++ b/appstores/android/app/src/main/assets/offline.html
@@ -0,0 +1,23 @@
+
+
+
+
PairUX is offline
+
+
No connection
+
PairUX needs the network to reach pairux.com.
+
diff --git a/appstores/android/app/src/main/java/com/pairux/app/MainActivity.java b/appstores/android/app/src/main/java/com/pairux/app/MainActivity.java
new file mode 100644
index 00000000..e953bc4d
--- /dev/null
+++ b/appstores/android/app/src/main/java/com/pairux/app/MainActivity.java
@@ -0,0 +1,237 @@
+package com.pairux.app;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.CookieManager;
+import android.webkit.PermissionRequest;
+import android.webkit.ValueCallback;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceError;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.FrameLayout;
+
+import android.Manifest;
+import android.content.pm.PackageManager;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * PairUX (pairux.com) on Android: the web app in a full-screen WebView.
+ *
+ * The site is already responsive and installable, so this adds only what a
+ * browser tab cannot: a launcher entry, $DOMAIN links opening here, the system
+ * Back button walking the page's own history, file uploads, full-screen video,
+ * and an offline screen instead of Chrome's dinosaur.
+ */
+public class MainActivity extends Activity {
+
+ static final String HOME = "https://pairux.com/";
+ static final String OFFLINE = "file:///android_asset/offline.html";
+ static final int MEDIA_PERMISSIONS = 1;
+ static final int FILE_CHOOSER = 2;
+
+ private WebView web;
+ private FrameLayout root;
+ private View fullscreen;
+ private WebChromeClient.CustomViewCallback fullscreenDone;
+ private PermissionRequest pendingPermission;
+ private ValueCallback pendingFiles;
+
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+
+ root = new FrameLayout(this);
+ root.setBackgroundColor(0xFF0F172A);
+ web = new WebView(this);
+ web.setBackgroundColor(0xFF0F172A);
+ root.addView(web, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ setContentView(root);
+
+ WebSettings s = web.getSettings();
+ s.setJavaScriptEnabled(true);
+ s.setDomStorageEnabled(true);
+ s.setDatabaseEnabled(true);
+ s.setMediaPlaybackRequiresUserGesture(false);
+ s.setLoadWithOverviewMode(true);
+ s.setUseWideViewPort(true);
+ s.setSupportZoom(false);
+ s.setBuiltInZoomControls(false);
+ s.setUserAgentString(s.getUserAgentString() + " pairux-android");
+ CookieManager.getInstance().setAcceptThirdPartyCookies(web, true);
+
+ web.setWebViewClient(new Client());
+ web.setWebChromeClient(new Chrome());
+
+ if (state != null) web.restoreState(state);
+ else handle(getIntent(), true);
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ handle(intent, false);
+ }
+
+ /** A tapped pairux.com link opens that page; the launcher icon opens the home page. */
+ private void handle(Intent intent, boolean launching) {
+ Uri data = intent != null ? intent.getData() : null;
+ if (data != null && "pairux.com".equalsIgnoreCase(data.getHost())) web.loadUrl(data.toString());
+ else if (launching) web.loadUrl(HOME);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle out) {
+ super.onSaveInstanceState(out);
+ web.saveState(out);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (fullscreen != null) { exitFullscreen(); return; }
+ if (web.canGoBack()) { web.goBack(); return; }
+ super.onBackPressed();
+ }
+
+ @Override
+ protected void onDestroy() {
+ web.destroy();
+ super.onDestroy();
+ }
+
+ private class Client extends WebViewClient {
+ /** Our own pages stay in the app; anything else is someone else's site. */
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
+ Uri url = request.getUrl();
+ String host = url.getHost();
+ if (host == null || host.equalsIgnoreCase("pairux.com") || host.endsWith(".pairux.com")) return false;
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, url));
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
+ if (request.isForMainFrame()) view.loadUrl(OFFLINE);
+ }
+ }
+
+ private class Chrome extends WebChromeClient {
+ /** getUserMedia: grant only what Android already granted us, else ask first. */
+ @Override
+ public void onPermissionRequest(final PermissionRequest request) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ List grant = new ArrayList<>();
+ List need = new ArrayList<>();
+ for (String r : request.getResources()) {
+ if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(r)) {
+ if (held(Manifest.permission.RECORD_AUDIO)) grant.add(r);
+ else need.add(Manifest.permission.RECORD_AUDIO);
+ } else if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(r)) {
+ if (held(Manifest.permission.CAMERA)) grant.add(r);
+ else need.add(Manifest.permission.CAMERA);
+ }
+ }
+ if (!need.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ pendingPermission = request;
+ requestPermissions(need.toArray(new String[0]), MEDIA_PERMISSIONS);
+ return;
+ }
+ if (grant.isEmpty()) request.deny();
+ else request.grant(grant.toArray(new String[0]));
+ }
+ });
+ }
+
+ @Override
+ public void onPermissionRequestCanceled(PermissionRequest request) {
+ if (pendingPermission == request) pendingPermission = null;
+ }
+
+ /** Without this an does nothing at all in a WebView. */
+ @Override
+ public boolean onShowFileChooser(WebView view, ValueCallback files,
+ FileChooserParams params) {
+ if (pendingFiles != null) pendingFiles.onReceiveValue(null);
+ pendingFiles = files;
+ try {
+ startActivityForResult(params.createIntent(), FILE_CHOOSER);
+ return true;
+ } catch (Exception e) {
+ pendingFiles = null;
+ return false;
+ }
+ }
+
+ @Override
+ public void onShowCustomView(View view, CustomViewCallback done) {
+ if (fullscreen != null) { done.onCustomViewHidden(); return; }
+ fullscreen = view;
+ fullscreenDone = done;
+ root.addView(view, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ web.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onHideCustomView() {
+ exitFullscreen();
+ }
+ }
+
+ private void exitFullscreen() {
+ if (fullscreen == null) return;
+ root.removeView(fullscreen);
+ fullscreen = null;
+ web.setVisibility(View.VISIBLE);
+ if (fullscreenDone != null) fullscreenDone.onCustomViewHidden();
+ fullscreenDone = null;
+ }
+
+ private boolean held(String permission) {
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
+ || checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int code, String[] permissions, int[] results) {
+ if (code != MEDIA_PERMISSIONS || pendingPermission == null) return;
+ PermissionRequest request = pendingPermission;
+ pendingPermission = null;
+ List grant = new ArrayList<>();
+ for (String r : request.getResources()) {
+ if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(r) && held(Manifest.permission.RECORD_AUDIO))
+ grant.add(r);
+ else if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(r) && held(Manifest.permission.CAMERA))
+ grant.add(r);
+ }
+ if (grant.isEmpty()) request.deny();
+ else request.grant(grant.toArray(new String[0]));
+ }
+
+ @Override
+ protected void onActivityResult(int code, int result, Intent data) {
+ if (code != FILE_CHOOSER) { super.onActivityResult(code, result, data); return; }
+ if (pendingFiles == null) return;
+ pendingFiles.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result, data));
+ pendingFiles = null;
+ }
+}
diff --git a/appstores/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/appstores/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..69b891ed
Binary files /dev/null and b/appstores/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/appstores/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/appstores/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..aa26b689
Binary files /dev/null and b/appstores/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/appstores/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/appstores/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..45790cc6
Binary files /dev/null and b/appstores/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/appstores/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/appstores/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..062acd64
Binary files /dev/null and b/appstores/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/appstores/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/appstores/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..94b3d1fc
Binary files /dev/null and b/appstores/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/appstores/android/app/src/main/res/values/strings.xml b/appstores/android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..7c613288
--- /dev/null
+++ b/appstores/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ PairUX
+
diff --git a/appstores/android/app/src/main/res/values/styles.xml b/appstores/android/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..8f8aa502
--- /dev/null
+++ b/appstores/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,14 @@
+
+
+ #0f172a
+ #3b82f6
+
+
+
diff --git a/appstores/android/build.gradle b/appstores/android/build.gradle
new file mode 100644
index 00000000..bb97a9ac
--- /dev/null
+++ b/appstores/android/build.gradle
@@ -0,0 +1 @@
+plugins { id 'com.android.application' version '8.5.2' apply false }
diff --git a/appstores/android/build.sh b/appstores/android/build.sh
new file mode 100755
index 00000000..aff260f3
--- /dev/null
+++ b/appstores/android/build.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+# Builds the Play bundle and a sideloadable APK. Runs inside gradle:8.7-jdk17:
+# docker run --rm -e OWNER=\$(id -u):\$(id -g) \\
+# -v "\$PWD/android-sdk:/sdk" -v "\$PWD/keys:/keys" \\
+# -v "\$PWD/appstores/android:/w" -w /w gradle:8.7-jdk17 sh build.sh
+# /sdk Android SDK cache (installed on first run)
+# /keys optional: upload.jks + upload.env to sign with
+set -eu
+SDK=/sdk
+TOOLS=commandlinetools-linux-11076708_latest.zip
+if [ ! -x "$SDK/cmdline-tools/latest/bin/sdkmanager" ]; then
+ mkdir -p "$SDK/cmdline-tools"
+ curl -fsSL "https://dl.google.com/android/repository/$TOOLS" -o /tmp/tools.zip
+ unzip -q /tmp/tools.zip -d "$SDK/cmdline-tools"
+ mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
+fi
+yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" --licenses >/dev/null
+"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" "platforms;android-35" "build-tools;35.0.0" >/dev/null
+export ANDROID_HOME="$SDK"
+
+if [ -f /keys/upload.jks ]; then
+ set -a; . /keys/upload.env; set +a
+ export ANDROID_KEYSTORE=/keys/upload.jks
+fi
+
+# bundleRelease is what Play takes; assembleRelease is for sideloading and testing.
+gradle --no-daemon -q lint bundleRelease assembleRelease
+mkdir -p dist
+cp app/build/outputs/bundle/release/app-release.aab "dist/pairux.aab"
+APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
+cp "$APK" "dist/pairux.apk"
+"$SDK/build-tools/35.0.0/apksigner" verify --print-certs "dist/pairux.apk" | head -3 || true
+"$SDK/build-tools/35.0.0/aapt2" dump badging "dist/pairux.apk" | grep -E "^(package|sdkVersion|targetSdkVersion|application-label)" || true
+[ -n "${OWNER:-}" ] && chown -R "$OWNER" dist app/build .gradle 2>/dev/null
+ls -l dist
diff --git a/appstores/android/gradle.properties b/appstores/android/gradle.properties
new file mode 100644
index 00000000..4b178b1d
--- /dev/null
+++ b/appstores/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2g
+android.nonTransitiveRClass=true
+android.useAndroidX=true
diff --git a/appstores/android/new-key.sh b/appstores/android/new-key.sh
new file mode 100755
index 00000000..ff0ba062
--- /dev/null
+++ b/appstores/android/new-key.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Mints the Play upload key once. Runs inside eclipse-temurin:17-jdk with the output
+# dir mounted at /keys. Store both files in the vault (pairux-android--signing as
+# ANDROID_KEYSTORE_B64 + ANDROID_STORE_PASSWORD), never in the repo.
+#
+# This is the UPLOAD key, not the app signing key: Play keeps the signing key once
+# you enrol in Play App Signing, so losing this one is recoverable (Google can reset
+# it) while losing a Play-managed signing key is not. Do not run this twice anyway.
+set -eu
+[ -f /keys/upload.jks ] && { echo "refusing: /keys/upload.jks exists"; exit 1; }
+umask 077
+PW=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')
+keytool -genkeypair -keystore /keys/upload.jks -storetype PKCS12 -alias pairux \
+ -keyalg RSA -keysize 4096 -validity 36500 -storepass "$PW" \
+ -dname "CN=PairUX (pairux.com), O=Profullstack, C=US" >/dev/null 2>&1
+printf 'ANDROID_STORE_PASSWORD=%s\nANDROID_KEY_ALIAS=pairux\n' "$PW" > /keys/upload.env
+[ -n "${OWNER:-}" ] && chown "$OWNER" /keys/upload.jks /keys/upload.env
+keytool -list -keystore /keys/upload.jks -storepass "$PW" | tail -2
diff --git a/appstores/android/settings.gradle b/appstores/android/settings.gradle
new file mode 100644
index 00000000..3b4633ef
--- /dev/null
+++ b/appstores/android/settings.gradle
@@ -0,0 +1,9 @@
+pluginManagement {
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+rootProject.name = "pairux-android"
+include ":app"
diff --git a/appstores/android/store/listing.md b/appstores/android/store/listing.md
new file mode 100644
index 00000000..075f17d8
--- /dev/null
+++ b/appstores/android/store/listing.md
@@ -0,0 +1,28 @@
+# Play listing: PairUX
+
+- **App name:** PairUX
+- **Package:** `com.pairux.app`
+- **Category:** Productivity
+- **Price:** free, no ads, no in-app purchases
+- **Contact:** anthony@profullstack.com
+- **Privacy policy:** https://pairux.com/privacy
+
+## Short description (80 characters max)
+
+Share a screen and hand over the mouse. Open source Screenhero.
+
+## Full description
+
+See `../../firetv/store/listing.md` for the long description; it carries over
+word for word. What does not carry over is the art: Play wants its own.
+
+## Art Play requires
+
+| Slot | Size | File |
+| ----------------- | ----------------------------- | -------------------------- |
+| App icon | 512x512 PNG | `out/icon-512.png` |
+| Feature graphic | 1024x500 PNG, no transparency | `out/feature-1024x500.png` |
+| Phone screenshots | 2-8, min 320px, 16:9 or 9:16 | `shots/` |
+
+Phone screenshots are not the Fire TV ones: those are 1920x1080 landscape of a
+TV layout. Capture the responsive site at phone size instead.
diff --git a/appstores/android/store/render.sh b/appstores/android/store/render.sh
new file mode 100755
index 00000000..dfcacaac
--- /dev/null
+++ b/appstores/android/store/render.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+# Renders the launcher icons and the Play listing art from store/src.
+# docker run --rm -v "\$PWD/appstores/android:/w" -w /w alpine:3.20 sh store/render.sh
+# Play wants a 512x512 icon and a 1024x500 feature graphic; the feature graphic is
+# flattened because Play rejects transparency there.
+set -eu
+apk add --no-cache imagemagick rsvg-convert >/dev/null
+R=app/src/main/res
+O=store/out
+S=store/src
+mkdir -p "$O"
+
+if [ -f "$S/mark.png" ]; then MARK="$S/mark.png"; else
+ rsvg-convert -w 1024 -h 1024 "$S/mark.svg" -o /tmp/mark.png; MARK=/tmp/mark.png; fi
+
+for d in mdpi:48 hdpi:72 xhdpi:96 xxhdpi:144 xxxhdpi:192; do
+ dpi=${d%%:*}; px=${d##*:}
+ mkdir -p "$R/mipmap-$dpi"
+ magick "$MARK" -resize ${px}x${px} "$R/mipmap-$dpi/ic_launcher.png"
+done
+
+magick "$MARK" -resize 512x512 "$O/icon-512.png"
+
+if [ -f "$S/wordmark.png" ]; then LOCK="$S/wordmark.png"; elif [ -f "$S/wordmark.svg" ]; then
+ rsvg-convert -w 900 "$S/wordmark.svg" -o /tmp/lock.png; LOCK=/tmp/lock.png; else LOCK="$MARK"; fi
+magick -size 1024x500 xc:'#0f172a' \( "$LOCK" -resize 760x340 \) -gravity center -composite \
+ -alpha remove -alpha off "$O/feature-1024x500.png"
+
+[ -n "${OWNER:-}" ] && chown -R "$OWNER" "$R" "$O"
+ls -l "$O"
diff --git a/appstores/android/store/src/mark.svg b/appstores/android/store/src/mark.svg
new file mode 100644
index 00000000..d5e8ef28
--- /dev/null
+++ b/appstores/android/store/src/mark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/appstores/android/store/src/wordmark.svg b/appstores/android/store/src/wordmark.svg
new file mode 100644
index 00000000..cbad525d
--- /dev/null
+++ b/appstores/android/store/src/wordmark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file