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