mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 11:23:35 -05:00
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
348 lines
15 KiB
Bash
Executable File
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"
|