Files
ps5upload/scripts/release/android-postinit-patch.sh
T
phantomptrandClaude Opus 5.5 6489b716c2 Android: the launcher shows the PS5Upload logo on every build
The logo was only applied when the Android project was first created, so an
existing one kept Tauri's placeholder, and the round icon was a small tile
inside a circle. scripts/release/android-icons.py draws the set from the icon
source (adaptive logo in the safe zone over white; full-bleed white tile and
disc for older launchers), and the post-init patch copies it on every build,
local and CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:02:02 -07:00

348 lines
15 KiB
Bash
Executable File

#!/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 <uses-permission> 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'
<?xml version="1.0" encoding="utf-8"?>
<!-- Loopback-only cleartext: the in-process ps5upload-engine binds
127.0.0.1:19113 and the WebView fetches it over plain HTTP. All
other traffic stays cleartext-forbidden. -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
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 version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="." />
</cloud-backup>
<device-transfer>
<include domain="file" path="." />
</device-transfer>
</data-extraction-rules>
XML
echo "wrote $xmldir/data_extraction_rules.xml"
cat >"$xmldir/backup_rules.xml" <<'XML'
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="file" path="." />
</full-backup-content>
XML
echo "wrote $xmldir/backup_rules.xml"
# --- patch <application> 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"(<application\b)", r"\1" + inject, src, count=1)
if new == src:
sys.exit("::error::could not find <application> tag to patch")
src = new
print("patched <application> with:" + inject)
else:
print("<application> 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 </application>'s closing, just
# after the opening <application ...> line, idempotently.
perms = [
'<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />',
'<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />',
'<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />',
# 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).
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />',
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />',
'<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />',
]
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"(<manifest\b[^>]*>)", r"\1" + block, src, count=1)
if new == src:
sys.exit("::error::could not find <manifest> 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 = '<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />'
if "android.permission.ACCESS_LOCAL_NETWORK" in src:
print("ACCESS_LOCAL_NETWORK already declared — skipping")
else:
new = re.sub(r"(<manifest\b[^>]*>)", r"\1\n " + perm, src, count=1)
if new == src:
sys.exit("::error::could not find <manifest> 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"