mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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
619 lines
26 KiB
Bash
Executable File
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"
|