Files
ps5upload/scripts/release/android-postinit-patch.sh
phantomptrandClaude Opus 5.5 413703be89 Host: background watcher and Android transfer service (#457)
bg_activity.rs polls the engine's /api/activity every second from Rust, so a
hidden or frozen WebView no longer decides whether work is running or has
ended. A pure step() turns each snapshot into start/update/finished/stop
actions: the first poll only records seq, an engine restart resets it, a
renderer hold refreshed within 5 minutes counts as work, and nothing active for
30 s stops. Finished jobs are emitted to the page (bg-job-finished, plus a
bg-tick heartbeat while work runs) and announced through the OS when the app is
in the background, in the language the page sends.

Android: a connectedDevice foreground service (dataSync as fallback) with a
partial wake lock and a Wi-Fi lock keeps the process and its in-process engine
alive, with an ongoing progress notification and a separate result channel. The
Kotlin class, manifest entries and R8 keep rules are written by
android-postinit-patch.sh. JNI_OnLoad and the context helpers move to
android_jni.rs so both callers share them; app classes load through the
context's class loader. The mobile engine start is now once per process, as
Activity recreation can run setup again.

Desktop: notifications through the notification plugin's Rust API when the
main window is hidden, minimised or unfocused; backgroundThrottling disabled on
the window (macOS 14+).

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

619 lines
26 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.
#
# 6) TransferService, the foreground service that keeps a transfer running
# with the app in the background (#457), its manifest entry, permissions
# and R8 keep rules. See step (6) below.
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
# --- (6) background transfers: TransferService (#457) ------------------
# Android freezes a backgrounded app's process, and the engine runs inside it,
# so a long upload stopped when the person switched apps. A foreground service
# keeps the process running while a job is active; the Rust watcher
# (src-tauri/src/bg_activity.rs → android_bg.rs) starts, updates and stops it
# through the static methods below and posts complete / failed notifications
# through it. Type connectedDevice (the PS5 is a device on the network; Android
# 15 caps dataSync at 6 h a day), dataSync declared as the fallback. The class is
# rewritten on every run so a change here always reaches the build.
if [ -n "$mainactivity" ]; then
cat >"$(dirname "$mainactivity")/TransferService.kt" <<'KT'
package com.phantomptr.ps5upload
// Written by scripts/release/android-postinit-patch.sh; edit it there.
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
/** Keeps the app (and the engine inside it) running while a transfer goes on in the
* background. Driven from Rust (android_bg.rs); never started by anything else. */
class TransferService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!promote()) {
stopSelf()
return START_NOT_STICKY
}
instance = this
refreshLocks()
// Not sticky: a restarted process has a fresh engine with nothing to finish.
return START_NOT_STICKY
}
private fun promote(): Boolean {
val n = progressNotification(this)
val types = if (Build.VERSION.SDK_INT >= 29) {
intArrayOf(
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
intArrayOf(0)
}
for (type in types) {
try {
ServiceCompat.startForeground(this, PROGRESS_ID, n, type)
return true
} catch (e: Exception) {
Log.w(TAG, "startForeground type=$type: $e")
}
}
return false
}
// Held with a timeout and refreshed by every progress update, so a watcher that
// died without saying stop cannot keep the phone awake for long.
private fun refreshLocks() {
try {
val wl = wakeLock ?: (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ps5upload:transfer")
.also { it.setReferenceCounted(false); wakeLock = it }
wl.acquire(LOCK_MS)
val wf = wifiLock ?: (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager)
.let {
@Suppress("DEPRECATION")
it.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "ps5upload:transfer")
}
.also { it.setReferenceCounted(false); wifiLock = it }
if (!wf.isHeld) wf.acquire()
} catch (e: Exception) {
Log.w(TAG, "locks: $e")
}
}
// Android 15+: the time limit for this service type ran out; stop rather than be killed.
override fun onTimeout(startId: Int, fgsType: Int) {
Log.w(TAG, "foreground time limit reached (type=$fgsType)")
stopSelf()
}
override fun onDestroy() {
instance = null
try { wakeLock?.takeIf { it.isHeld }?.release() } catch (_: Exception) {}
try { wifiLock?.takeIf { it.isHeld }?.release() } catch (_: Exception) {}
wakeLock = null
wifiLock = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
companion object {
private const val TAG = "PS5UploadTransfer"
private const val PROGRESS_ID = 1
private const val CH_PROGRESS = "ps5upload_transfers"
private const val CH_RESULTS = "ps5upload_results"
private const val LOCK_MS = 10 * 60 * 1000L
@Volatile private var title = ""
@Volatile private var text = ""
@Volatile private var percent = -1
@Volatile private var instance: TransferService? = null
@JvmStatic
fun start(ctx: Context, title: String, text: String, percent: Int, channelName: String) {
set(title, text, percent)
channel(ctx, CH_PROGRESS, channelName, NotificationManager.IMPORTANCE_LOW)
try {
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
} catch (e: Exception) {
// Android 12+ refuses a start from the background
// (ForegroundServiceStartNotAllowedException): show the progress anyway.
Log.w(TAG, "start: $e")
post(ctx, PROGRESS_ID, progressNotification(ctx))
}
}
@JvmStatic
fun update(ctx: Context, title: String, text: String, percent: Int) {
set(title, text, percent)
post(ctx, PROGRESS_ID, progressNotification(ctx))
instance?.refreshLocks()
}
@JvmStatic
fun finished(ctx: Context, id: Int, title: String, text: String, ok: Boolean, channelName: String) {
channel(ctx, CH_RESULTS, channelName, NotificationManager.IMPORTANCE_DEFAULT)
val n = NotificationCompat.Builder(ctx, CH_RESULTS)
.setSmallIcon(if (ok) android.R.drawable.stat_sys_upload_done else android.R.drawable.stat_notify_error)
.setContentTitle(title)
.setContentText(text.lineSequence().firstOrNull() ?: "")
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setCategory(if (ok) NotificationCompat.CATEGORY_STATUS else NotificationCompat.CATEGORY_ERROR)
.setContentIntent(openApp(ctx))
.setAutoCancel(true)
.build()
post(ctx, id, n)
}
@JvmStatic
fun stop(ctx: Context) {
try {
ctx.stopService(Intent(ctx, TransferService::class.java))
} catch (e: Exception) {
Log.w(TAG, "stop: $e")
}
NotificationManagerCompat.from(ctx).cancel(PROGRESS_ID)
}
private fun set(t: String, x: String, p: Int) {
title = t
text = x
percent = p
}
private fun progressNotification(ctx: Context): Notification {
val b = NotificationCompat.Builder(ctx, CH_PROGRESS)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setContentIntent(openApp(ctx))
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
if (percent >= 0) b.setProgress(100, percent, false) else b.setProgress(0, 0, true)
return b.build()
}
// A tap brings the running app forward, as its launcher icon would.
private fun openApp(ctx: Context): PendingIntent {
val i = Intent(ctx, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return PendingIntent.getActivity(
ctx, 0, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private fun channel(ctx: Context, id: String, name: String, importance: Int) {
if (Build.VERSION.SDK_INT < 26) return
// Creating an existing channel only renames it (the language may have changed).
ctx.getSystemService(NotificationManager::class.java)
?.createNotificationChannel(NotificationChannel(id, name, importance))
}
// Without POST_NOTIFICATIONS (Android 13+, refused) the service still runs; only the
// notification is hidden.
private fun post(ctx: Context, id: Int, n: Notification) {
if (Build.VERSION.SDK_INT >= 33 &&
ctx.checkSelfPermission("android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED
) return
try {
NotificationManagerCompat.from(ctx).notify(id, n)
} catch (e: SecurityException) {
Log.w(TAG, "notify: $e")
}
}
}
}
KT
echo "wrote TransferService.kt"
fi
# R8 would rename or drop the static methods only JNI calls.
cat >"$root/client/src-tauri/gen/android/app/ps5upload-transfer.pro" <<'PRO'
# TransferService is driven from Rust over JNI (android_bg.rs); keep its names.
-keep class com.phantomptr.ps5upload.TransferService { *; }
-keep class com.phantomptr.ps5upload.TransferService$Companion { *; }
PRO
echo "wrote ps5upload-transfer.pro"
python3 - "$manifest" <<'PY'
import re, sys
path = sys.argv[1]
src = open(path, encoding="utf-8").read()
perms = [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE",
"android.permission.FOREGROUND_SERVICE_DATA_SYNC",
"android.permission.WAKE_LOCK",
]
# Exact attribute match: FOREGROUND_SERVICE is a prefix of the typed ones.
missing = [p for p in perms if f'android:name="{p}"' not in src]
if missing:
block = "".join(f'\n <uses-permission android:name="{p}" />' for p in 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 the service permissions")
src = new
print("added permissions:", ", ".join(missing))
else:
print("service permissions already present — skipping")
if ".TransferService" in src:
print("TransferService already declared — skipping")
else:
service = (' <service android:name=".TransferService" android:exported="false"\n'
' android:foregroundServiceType="connectedDevice|dataSync" />\n')
new = src.replace(" </application>", service + " </application>", 1)
if new == src:
sys.exit("::error::could not find </application> to declare TransferService")
src = new
print("declared TransferService")
open(path, "w", encoding="utf-8").write(src)
PY
echo "android post-init patches applied"