#!/usr/bin/env bash # # Post-`tauri android init` patches for the Android build. `gen/android` # is gitignored and regenerated by `tauri android init`, so these MUST # run AFTER init and BEFORE `tauri android build`. Idempotent. Invoked # from publish.yml, engine-ci.yml, and the Makefile `android-init` target. # # Patches (each idempotent): # # 1) Loopback cleartext for the in-process engine. # The mobile build links ps5upload-engine in-process and binds it to # http://127.0.0.1:19113; the renderer fetches that URL like the # desktop sidecar (engine_mobile.rs + client/src/api/engine.ts). Tauri # sets android:usesCleartextTraffic=true for debug but FALSE for # release, so a release APK silently blocks the loopback fetch and the # UI shows the engine "red". We add a network-security-config that # permits cleartext ONLY for 127.0.0.1 / localhost; everything else # stays cleartext-forbidden. networkSecurityConfig takes precedence # over the usesCleartextTraffic flag for those domains. # # 2) Explicit backup rules so user settings survive an uninstall/reinstall # (cloud backup / device transfer). Settings already persist across # normal restarts (app-private config dir) and in-place updates (now # that releases share a stable signing key), but allowBackup + explicit # include rules let Android restore them on a fresh reinstall too. # # 3) The PS5Upload launcher icons, copied from client/src-tauri/icons/android. # # Plus injection: all-files storage access for the # in-app picker, and Wi-Fi multicast/state permissions so mDNS console # discovery can receive 224.0.0.251:5353 (see the perms list below). # The MulticastLock itself is acquired via JNI at runtime — see # local_fs.rs::acquire_multicast_lock, called from discover_ps5. # # 3) Pin WebView textZoom to 100% in MainActivity. Android WebView scales # every font by the device's system **Font size** accessibility setting, # so a user with a larger-than-default font sees text blown up — dense rows # (Library, Upload) clip to the first few characters. Overriding the # onWebViewCreate hook (exposed by WryActivity) to set textZoom = 100 makes # the app render at its designed size regardless of that setting. # # NOTE: textZoom only counters **Font size**, NOT **Display size**. Display # size changes the WebView's density and scales the WHOLE viewport (icons, # padding, controls — not just text), which textZoom can't touch. That case # is handled in-app by the user-adjustable "Text size" setting # (client/src/state/uiScale.ts), which rescales the rem root and so resizes # the entire UI. The two fixes are complementary. set -euo pipefail root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" gen="$root/client/src-tauri/gen/android/app/src/main" manifest="$gen/AndroidManifest.xml" xmldir="$gen/res/xml" if [ ! -f "$manifest" ]; then echo "::error::AndroidManifest.xml not found at $manifest — run 'tauri android init' first" >&2 exit 1 fi # Launcher icons: the committed PS5Upload set (scripts/release/android-icons.py) over # whatever `tauri android init` seeded. On every build, not only at init, so an # existing gen/ never keeps the placeholder icon. icons="$root/client/src-tauri/icons/android" if [ -d "$icons" ]; then rm -f "$gen/res/drawable/ic_launcher_background.xml" "$gen/res/drawable-v24/ic_launcher_foreground.xml" cp -R "$icons/." "$gen/res/" echo "applied the PS5Upload launcher icons" fi mkdir -p "$xmldir" # --- (1) loopback-only cleartext -------------------------------------- cat >"$xmldir/network_security_config.xml" <<'XML' 127.0.0.1 localhost XML echo "wrote $xmldir/network_security_config.xml" # --- (2) backup rules ------------------------------------------------- # Android 12+ uses dataExtractionRules; older uses fullBackupContent. # Include the whole app files domain (where settings.json lives) for both # cloud backup and device-to-device transfer. cat >"$xmldir/data_extraction_rules.xml" <<'XML' XML echo "wrote $xmldir/data_extraction_rules.xml" cat >"$xmldir/backup_rules.xml" <<'XML' XML echo "wrote $xmldir/backup_rules.xml" # --- patch attributes, idempotently --------------------- python3 - "$manifest" <<'PY' import re, sys path = sys.argv[1] src = open(path, encoding="utf-8").read() wanted = { "android:networkSecurityConfig": "@xml/network_security_config", "android:allowBackup": "true", "android:fullBackupContent": "@xml/backup_rules", "android:dataExtractionRules": "@xml/data_extraction_rules", } inject = "".join( f' {k}="{v}"' for k, v in wanted.items() if k not in src ) if inject: new = re.sub(r"( tag to patch") src = new print("patched with:" + inject) else: print(" already patched — skipping") # All-files storage access so the in-app file/folder picker can read # arbitrary game folders / .zips under /storage (the engine walks real FS # paths; SAF content:// URIs are unusable). MANAGE_EXTERNAL_STORAGE is the # API 30+ permission; the legacy READ/WRITE_EXTERNAL_STORAGE (maxSdk 29) # cover older devices. Inserted before 's closing, just # after the opening line, idempotently. perms = [ '', '', '', # mDNS console discovery: receiving multicast (224.0.0.251:5353) # requires CHANGE_WIFI_MULTICAST_STATE; without it the Wi-Fi stack # filters multicast frames and discovery silently finds nothing. # ACCESS_NETWORK_STATE / ACCESS_WIFI_STATE let the engine pick the # right interface. The MulticastLock is acquired at runtime via JNI # (see local_fs.rs::acquire_multicast_lock, called from discover_ps5). '', '', '', ] missing = [p for p in perms if 'android.permission.' + p.split('android.permission.')[1].split('"')[0] not in src] if missing: block = "\n " + "\n ".join(missing) new = re.sub(r"(]*>)", r"\1" + block, src, count=1) if new == src: sys.exit("::error::could not find tag to add permissions") src = new print("added permissions:", ", ".join(p.split('"')[1] for p in missing)) else: print("permissions already present — skipping") open(path, "w", encoding="utf-8").write(src) PY # --- (3) pin WebView textZoom in MainActivity ------------------------ # MainActivity.kt is the user-owned activity (not under generated/); we add # an onWebViewCreate override that pins textZoom so the device font-scale # doesn't enlarge the UI. Idempotent: skipped if the override already exists. mainactivity="$(find "$gen/java" -name MainActivity.kt -print -quit 2>/dev/null || true)" if [ -z "$mainactivity" ]; then echo "::warning::MainActivity.kt not found under $gen/java — skipping textZoom patch" >&2 else python3 - "$mainactivity" <<'PY' import sys path = sys.argv[1] src = open(path, encoding="utf-8").read() if "onWebViewCreate" in src: print("MainActivity.kt already has onWebViewCreate — skipping") sys.exit(0) method = ''' // Pin WebView text zoom to 100% so the UI renders at its designed 18px base // regardless of the device's system Font size / Display size accessibility // setting (Android WebView otherwise multiplies every font by it, which on a // larger-than-default font overflows the dense rem-based layout and clips // e.g. Library/Upload rows to "PPS..."). textZoom affects only text size, // not pinch-zoom. override fun onWebViewCreate(webView: android.webkit.WebView) { webView.settings.textZoom = 100 super.onWebViewCreate(webView) } ''' idx = src.rstrip().rfind("}") if idx == -1: sys.exit("::error::could not find class closing brace in MainActivity.kt") src = src[:idx] + method + src[idx:] open(path, "w", encoding="utf-8").write(src) print("patched MainActivity.kt with onWebViewCreate textZoom=100") PY fi # --- (4) hand the page the navigation-bar inset ---------------------- # The app draws edge-to-edge (enableEdgeToEdge, targetSdk 36), and the CSS pads # the bottom nav with env(safe-area-inset-bottom). Android WebView does not # report the navigation bar there: measured in the app on the emulator # (Chrome 134), safe-area-inset-top was 49px but safe-area-inset-bottom was # 0px, so the bottom tab bar sat under the gesture bar and its labels were # covered. Expose the real inset through a JS interface the page reads. # # Never install an insets listener on the WebView: it replaces the WebView's # own, and that is what computes the (correct) top inset. Idempotent via the # interface name. if [ -n "$mainactivity" ]; then python3 - "$mainactivity" <<'PY' import sys path = sys.argv[1] src = open(path, encoding="utf-8").read() if "PS5UploadInsets" in src: print("MainActivity.kt already exposes PS5UploadInsets — skipping") sys.exit(0) anchor = " super.onWebViewCreate(webView)\n" if anchor not in src: sys.exit("::error::onWebViewCreate not found in MainActivity.kt (step 3 must run first)") hook = anchor + ''' // WebView reports env(safe-area-inset-bottom) as 0 for the navigation // bar (measured: top 49px, bottom 0px), so give the page the real value. webView.addJavascriptInterface(object { @android.webkit.JavascriptInterface fun navBottomPx(): Int = navInsetBottomPx }, "PS5UploadInsets") // Read the insets from the window root on each layout rather than // installing an insets listener on the WebView. A listener REPLACES the // WebView's own, which is what computes its safe-area-inset-top: the first // version of this did that and the header slid under the status bar // (measured top inset 49px -> 0px), even with the default handling // re-invoked. A layout observer changes nothing about inset dispatch. val root = window.decorView root.viewTreeObserver.addOnGlobalLayoutListener { val insets = androidx.core.view.ViewCompat.getRootWindowInsets(root) ?: return@addOnGlobalLayoutListener val bottom = insets .getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()) .bottom if (bottom != navInsetBottomPx) { navInsetBottomPx = bottom webView.evaluateJavascript( "window.dispatchEvent(new Event('ps5upload-insets'))", null) } } ''' src = src.replace(anchor, hook, 1) field = ''' // Navigation-bar height in physical pixels, read by the page through // PS5UploadInsets. Written on the UI thread, read on WebView's binder thread. @Volatile private var navInsetBottomPx: Int = 0 ''' brace = src.index("{", src.index("class MainActivity")) src = src[:brace + 1] + field + src[brace + 1:] open(path, "w", encoding="utf-8").write(src) print("patched MainActivity.kt to expose the navigation-bar inset") PY fi # --- (5) Android 17 local network permission ------------------------- # From targetSdk 37 (which `tauri android init` now generates), Android 17 # blocks an app's connections to devices on the local network unless it holds # ACCESS_LOCAL_NETWORK, a runtime permission. Without it every connection to the # PS5 failed and the app said "Port 9021 is not open" while the phone's own # shell reached the port (6.x on Android; 5.41.0 targeted 36 and was not # affected). Declare it, ask for it at launch, and let the page check it and ask # again through PS5UploadNet, so the Connection screen can name the real cause. python3 - "$manifest" <<'PY' import re, sys path = sys.argv[1] src = open(path, encoding="utf-8").read() perm = '' if "android.permission.ACCESS_LOCAL_NETWORK" in src: print("ACCESS_LOCAL_NETWORK already declared — skipping") else: new = re.sub(r"(]*>)", r"\1\n " + perm, src, count=1) if new == src: sys.exit("::error::could not find tag to add ACCESS_LOCAL_NETWORK") open(path, "w", encoding="utf-8").write(new) print("declared ACCESS_LOCAL_NETWORK") PY if [ -n "$mainactivity" ]; then python3 - "$mainactivity" <<'PY' import sys path = sys.argv[1] src = open(path, encoding="utf-8").read() if "PS5UploadNet" in src: print("MainActivity.kt already exposes PS5UploadNet — skipping") sys.exit(0) create_anchor = " super.onCreate(savedInstanceState)\n" webview_anchor = " super.onWebViewCreate(webView)\n" if create_anchor not in src or webview_anchor not in src: sys.exit("::error::onCreate/onWebViewCreate not found in MainActivity.kt") src = src.replace(create_anchor, create_anchor + " requestLocalNetwork()\n", 1) src = src.replace(webview_anchor, webview_anchor + ''' // The page asks whether it may reach the local network, and asks again // (or opens the app's settings once Android stops showing the prompt). webView.addJavascriptInterface(object { @android.webkit.JavascriptInterface fun granted(): Boolean = localNetworkGranted() @android.webkit.JavascriptInterface fun request() { runOnUiThread { requestLocalNetwork(openSettings = true) } } }, "PS5UploadNet") ''', 1) methods = ''' // Android 17+ (targetSdk 37): connections to the PS5 need ACCESS_LOCAL_NETWORK. private fun localNetworkGranted(): Boolean = android.os.Build.VERSION.SDK_INT < 37 || checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") == android.content.pm.PackageManager.PERMISSION_GRANTED private fun requestLocalNetwork(openSettings: Boolean = false) { if (localNetworkGranted()) return val perm = "android.permission.ACCESS_LOCAL_NETWORK" // After a second "Don't allow" Android no longer shows the prompt, so a // request from the page opens the app's settings instead. if (openSettings && localNetworkAsked && !shouldShowRequestPermissionRationale(perm)) { startActivity( android.content.Intent( android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", packageName, null), ), ) return } localNetworkAsked = true requestPermissions(arrayOf(perm), 4217) } private var localNetworkAsked = false ''' idx = src.rstrip().rfind("}") src = src[:idx] + methods + src[idx:] open(path, "w", encoding="utf-8").write(src) print("patched MainActivity.kt to request ACCESS_LOCAL_NETWORK (PS5UploadNet)") PY fi echo "android post-init patches applied"