diff --git a/.gitignore b/.gitignore index 72abdd3..9135cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Device partition backups — contain the frame's serial number and Wi-Fi/BT MAC. +# Never push these to a remote; see FRAMEO.md. +/backups/ + +# Device identifiers (serial, LAN address) — keep out of the public repo. +/scripts/frame.env diff --git a/FRAMEO.md b/FRAMEO.md new file mode 100644 index 0000000..9b06e3e --- /dev/null +++ b/FRAMEO.md @@ -0,0 +1,343 @@ +# Photo frame device notes + +Current device: **Pexar Frame PX-110**. The Frameo 106K it replaced died of eMMC +failure and went back to Amazon; what still transfers is kept at the +[end](#previous-device-frameo-106k-retired). + +## Pexar Frame PX-110 (MediaTek MT8167) + +| | | +|---|---| +| Model | Pexar Frame (`ro.product.brand=Lexar`, `ro.product.device=dpf1106_mk_32`) | +| SoC | MediaTek MT8167, **32-bit only** (`armeabi-v7a`) | +| Android | 11, user build, `ro.debuggable=0`, SELinux **permissive** | +| RAM / storage | 2 GB / 26 GB `/data` | +| USB serial | in `scripts/frame.env` (gitignored) | +| IP | in `scripts/frame.env` (gitignored); it moves on DHCP, so check before assuming | + +Root works despite `ro.debuggable=0`, because `/system/xbin/su` is setuid root and +SELinux is permissive: + +```sh +adb shell /system/xbin/su 0 id # uid=0(root) ... context=u:r:su:s0 +``` + +Setup and health checks are scripted — see [scripts/px110.sh](scripts/px110.sh). + +### Build constraint: 32-bit only + +`ro.product.cpu.abilist` is `armeabi-v7a,armeabi`. The default fat APK from +`flutter build apk --release` works because it bundles `armeabi-v7a`, but an +arm64-only build or the wrong `--split-per-abi` artifact produces an APK this frame +cannot run: + +```sh +unzip -l build/app/outputs/flutter-apk/app-release.apk | grep -o 'lib/[^/]*' | sort -u +# must include lib/armeabi-v7a +``` + +Flutter also picks the JDK bundled with Android Studio first. That JBR is currently +OpenJDK 25, which Gradle 8.14 cannot parse — the build dies with a bare +`java.lang.IllegalArgumentException: 25.0.2`. Point Flutter at Temurin 21: + +```sh +flutter config --jdk-dir "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home" +``` + +That is a machine-local setting, not repo state. Undo with `flutter config --jdk-dir=`. + +### Vendor install lock (`ro.vendor.custom_recover`) + +Out of the box every sideload fails, including as root: + +``` +Failure [INSTALL_FAILED_INVALID_APK: Package io.github.micw.openphotoframe is not allow to install. ] +``` + +The ROM patches `PackageManagerService.preparePackageLI` in +`/system/framework/services.jar`: + +```java +if ("1".equals(SystemProperties.get("ro.vendor.custom_recover", "0"))) { + if (!isSystemApp(pkg.getPackageName())) { + throw new PrepareFailure(-2, "Package " + name + " is not allow to install. "); + } +} +``` + +`MtkSystemUI` reads the same property via `CommandQueue.isRecoveryFirstBoot()`, and +`panelsEnabled()` returns false while it is `1` — so the flag is a kiosk lockdown +switch that blocks sideloading *and* disables the notification shade. + +`/vendor/bin/nvram_daemon` publishes the value. Its code at `0x2eb4` (Thumb-2) reads +the 1024-byte product-info record, takes the byte at offset `0x3FE`, and calls +`property_set`. That record is the head of the **`proinfo` partition**, not a file +under `/mnt/vendor/nvdata`. `proinfo` starts with the device serial, which is how to +confirm the right partition: + +```sh +P=/dev/block/platform/soc/11120000.mmc/by-name/proinfo +adb shell "/system/xbin/su 0 dd if=$P bs=1 count=1024 2>/dev/null | od -A d -t x1" +# offset 0 serial +# offset 0x6e Wi-Fi/BT MAC +# offset 0x3fe the lock flag +``` + +**The fix** — clear byte 1022 and reboot. `proinfo` is a raw factory-data partition +outside the AVB chain, so this does not disturb verity: + +```sh +adb shell "/system/xbin/su 0 dd if=/dev/zero of=$P bs=1 seek=1022 count=1 conv=notrunc; sync" +adb reboot +adb shell getprop ro.vendor.custom_recover # 0 +``` + +Back the partition up first and diff afterwards — expect exactly one changed byte. +The pre-change backup is in `backups/`, which is gitignored because the dump +contains the serial and MAC. + +Side effect, as predicted by `panelsEnabled()`: the notification shade now pulls +down. Restoring the byte reverts both effects. + +**Why not patch `/system` instead.** It is dm-verity backed (`dm-3`) with +`ro.boot.veritymode=enforcing` and `ro.boot.vbmeta.device_state=locked`. +`mount -o rw,remount /` succeeds at the VFS layer but writes fail against dm-verity, +and remounting back to `ro` returns a harmless I/O error (the flag resets at reboot). +`adb disable-verity` and `adb remount` both need a userdebug build. Installing into +`/system/app` would therefore risk an unbootable frame; the `proinfo` byte avoids +verity entirely. + +### Stock services + +Surveyed 2026-09-15. Never `pm uninstall` these — Android falls back to the ROM copy, +which then runs and self-updates. + +**Do not `pm disable-user` a package marked `android:persistent="true"` on this +ROM.** ActivityManager respawns the persistent process, it dies instantly because the +package is disabled, and AMS respawns it again — see +[the outage](#the-2026-09-19-respawn-loop-outage). + +`com.DeviceTest` and `net.frameo.frame` are not persistent, so disabling them +outright is safe: + +```sh +adb shell /system/xbin/su 0 pm disable-user --user 0 com.DeviceTest +adb shell /system/xbin/su 0 pm disable-user --user 0 net.frameo.frame +``` + +`com.adups.fota` **is** persistent. Leave the package enabled and disable its +components, so the persistent process starts once and idles with nothing to trigger: + +```sh +for c in .receiver.MyReceiver .service.FcmService .GoogleOtaClient \ + com.google.firebase.iid.FirebaseInstanceIdReceiver \ + com.google.firebase.messaging.FirebaseMessagingService .activity.GdprActivity; do + adb shell /system/xbin/su 0 pm disable --user 0 "com.adups.fota/$c" +done +``` + +Afterwards `com.adups.fota` must report `enabled=0` or `enabled=1` — **never +`enabled=3`**. Its `Application.onCreate` still re-registers a daily `RTC_WAKEUP`, +but the alarm's `PendingIntent` targets the now-disabled `MyReceiver`, so it fires +into nothing. + +Why each one matters: + +- **`com.adups.fota` v5.30** (`/product/app/FotaApp/`) — OTA updater holding + `REBOOT`, `RECOVERY` and `SCHEDULE_EXACT_ALARM`. Beyond the daily alarm it + registers Firebase Cloud Messaging (`.service.FcmService`) alongside + `.GoogleOtaClient`, so an update can be pushed remotely at any time. +- **`com.DeviceTest`** (`/system/app/HCNDeviceTest/`) — shares the **system UID** and + holds `REBOOT`, `MASTER_CLEAR`, `DEVICE_POWER`. Its `BootReceiver` is a factory + burn-in trigger: + + ```java + if (!sp.getBoolean("istestend", true)) { // default TRUE + Intent i = new Intent(context, com.DeviceTest.AgingTest.class); + i.putExtra("Reboot", "reboot"); + context.startActivity(i); + } + ``` + + The default is `true` and `shared_prefs/` was empty, so it never armed. The risk is + latent: anything writing `istestend=false` gives a reboot on every boot. If a + future frame reboots on a fixed cycle, check the aging-test prefs before assuming + OTA. +- **`net.frameo.frame` v1.26.26** — no `REBOOT` here, so not a reboot vector, but it + holds `SET_TIME`, had a `StandbyBroadcastReceiver` wakeup that fights for the + screen, and is a self-update vector. + +Left enabled: `com.debug.loggerui` with the `aee_aed` / `aee_aedv` / `mobile_log_d` +daemons (~530 KB written, no wear pressure), `com.mediatek.engineermode` +(`MASTER_CLEAR` but UI-only), MiraVision, CallRecorderService, LocationEM2, +MtkCapCtrl. + +### Default launcher + +With `net.frameo.frame` disabled there are still two HOME candidates +(`com.android.launcher3` and the app) and no default, so the frame can boot to a +launcher chooser. Pin it: + +```sh +adb shell /system/xbin/su 0 cmd package set-home-activity \ + io.github.micw.openphotoframe/.MainActivity +``` + +Verify with the MAIN action included — `-c HOME` alone reports "No activity found" +even when the default is set correctly: + +```sh +adb shell cmd package resolve-activity \ + -a android.intent.action.MAIN -c android.intent.category.HOME --brief +``` + +### Web UI password + +The settings server binds `0.0.0.0:8080` with `Access-Control-Allow-Origin: *` +and shipped with no authentication, so anyone on the LAN could read +`/api/config` and `/api/log` — both of which disclose the iCloud shared-album +token, which is enough to open the album. + +Set a password in the web UI (Android section) or in the on-device settings +screen. Empty disables authentication. It is HTTP Basic, any username, checked +against `web_ui_password` in the app config: + +```sh +curl -u admin:PASSWORD http://$FRAME_IP:8080/api/config +``` + +`/metrics` is deliberately **left open** so Prometheus keeps scraping without +credentials — it exposes only counters and gauges, no album URLs, tokens or log +lines. If you want it protected too, add `basic_auth` to the Prometheus scrape +config and remove the `path != '/metrics'` exemption in `_route`. + +Forgotten password: clear it in the on-device settings screen, or over ADB — + +```sh +adb shell "/system/xbin/su 0 sed -i 's/\"web_ui_password\":[^,}]*/\"web_ui_password\":\"\"/' \ + /data/data/io.github.micw.openphotoframe/app_flutter/config.json" +``` + +Note the token is still written to the app log in plaintext, so it remains +visible to anyone who can authenticate or read the log another way. + +### Wi-Fi ADB and flaky USB + +USB on this frame re-enumerates constantly — transport ids climbed past 300 in one +session, with commands dying mid-run as `device not found`. That is not the frame +crashing; check that `/proc/uptime` keeps climbing. Work over Wi-Fi. + +TCP mode does not survive a reboot by itself, but the app restores it: with +`autostart_on_boot` and `wifi_adb_enabled` set in its config, `ScreenControlHandler` +re-runs `setprop service.adb.tcp.port 5555` and restarts `adbd` at boot. A cold boot +reaches photos in about 30 seconds with Wi-Fi ADB already back. Manual setup: + +```sh +adb -s "$FRAME_SERIAL" tcpip 5555 +adb connect "$FRAME_IP:5555" +``` + +### ADB quick reference + +```sh +# Wi-Fi (preferred — USB is unreliable) +adb -s "$FRAME_IP:5555" shell + +# Deploy (needs the install lock cleared first) +adb -s "$FRAME_IP:5555" install -r -g build/app/outputs/flutter-apk/app-release.apk + +# Metrics without adb +curl -s http://$FRAME_IP:8080/metrics | grep ^opf_uptime_seconds +``` + +### The 2026-09-19 respawn-loop outage + +The frame wedged showing "process system isn't responding" with dead touch. +Prometheus stopped scraping at 07:31 and the device never rebooted — uptime ran +continuously through the whole event. + +**Cause 1 — `pm disable-user` on a persistent package.** Disabling `com.adups.fota` +that way on 2026-09-15 produced an AMS respawn loop of **17 per minute, ~24,000 per +day, for four days**: + +``` +I ActivityManager: Process com.adups.fota (pid 12559) has died: pers PER +I ActivityManager: Process com.adups.fota (pid 12744) has died: pers PER # +3.5s +``` + +Re-enabling the package stops it immediately. + +**Cause 2 — unbounded process-scan pile-up in the app.** +`WebServerService._refreshProcessStatus()` shelled out per metrics scrape with a +`/proc/[0-9]*/cmdline` walk that forked two processes per PID (~520 per scan), and +assigned its throttle timestamp *after* the `await` with no in-flight guard. One slow +scan therefore let every later scrape launch another 520-fork scan on top of the +stuck one; an orphaned `tr` was found spinning at 100% of a core. Fixed with a single +`su 0 ps -A -o NAME`, a 10s timeout, the throttle set up front, and an in-flight +guard. + +**Why it looked like a system hang.** Sustained ~90% system CPU starved everything. +`system_server` ANR'd and its handler then jammed: thread `AnrConsumer` blocked in +`debuggerd_trigger_dump` → `recvfrom` while holding MediaTek's +`AnrManagerService$AnrDumpRecord` lock, so every later ANR queued behind it. Both +`system_server` and the app showed *idle* main threads (`epoll_wait` / +`nativePollOnce`) — CPU starvation plus a wedged ANR pipeline, not a deadlock. + +Diagnostic notes for next time: + +- **Load average is not a health signal here.** Six MTK kernel threads + (`amms_task`, `GCPU`, `hang_detect`, `entropy_thread`, `display_esd_che`, + `bat_thread_kthr`) sit permanently in `D` state and each counts toward load, so a + baseline near 7 is normal. Use `top`'s idle/sys split instead. +- ADB and the app's web server stayed responsive while the UI was frozen. Always try + `adb connect` and `curl :8080/metrics` before power-cycling. +- ANR traces are in `/data/anr/` (`anr_*` reports, `trace_*` dumped processes). They + rotate fast, so grab them early. +- Ruled out by metrics: memory (RSS 170 MB, JVM 4 MB of 805 MB, 1.2 GB free, + `opf_android_low_memory=0`), eMMC (`opf_emmc_io_errors_total` flat at 0, clean + dmesg), storage (25 GB free). + +## Previous device: Frameo 106K (retired) + +Rockchip RK3326, Android 11. Returned to Amazon after +progressive eMMC failure. Kept for the parts that transfer. + +**eMMC failure is a known failure mode for these frames.** Bad sectors appeared first +in the system partition (killing `statsd`, `audioserver`, `netd`, `wificond`), then +about an hour later 13 more in userdata — including f2fs node inode blocks, meaning +the filesystem itself was at risk. Community reports: +[1](https://forums.justuseapp.com/en/post/QERMOAEXL7/frameo-keeps-rebooting-over-and-over), +[2](https://www.justanswer.com/electronics/tksy5-frameo-digital-photo-frame-stuck-rebooting.html), +[3](https://www.devicepitfalls.com/frameo-stuck-on-startup-screen/). + +Mapping a bad sector to a file, if it happens again: + +```sh +adb shell su 0 cat /sys/kernel/debug/mmc0/mmc0:0001/ext_csd # eMMC health +# f2fs block = (bad_sector - partition_start) / 8 +adb shell su 0 dump.f2fs -b /dev/block/dm-6 +adb shell su 0 find /data -inum +``` + +**Factory reset without a usable recovery UI.** The frame had one physical button, +which only selected "Reboot system now". Reset via ADB instead: + +```sh +adb shell su 0 sh -c 'mkdir -p /cache/recovery && printf -- "--wipe_data\n" > /cache/recovery/command && sync' +adb reboot recovery +``` + +**Rockchip-only app workarounds.** Both are inert on the PX-110 and remain in the +codebase only for a possible future Rockchip device: + +- `ScreenControlHandler.stopCrashingMemtrackService()` — the RK3326 + `android.hardware.memtrack@1.0-service` HAL aborts on boot inside + `memtrack.rk3326.so` and init restarts it forever. Gated on `Build.HARDWARE` + containing `"rk"` (the PX-110 reports `mt8167`) and on + `/vendor/lib/hw/memtrack.rk3326.so`, which does not exist here. +- `ScreenControlHandler.warmEmmcFileCache()` — reads the 106K's failing system files + into the page cache at boot. Off by default; leave it off. + +Its `/vendor` was also dm-verity protected, with `adb remount`'s overlay landing on a +tmpfs at `/mnt/scratch` and no scratch partition, so no permanent firmware fix was +possible there either. diff --git a/FRAME_SETUP.md b/FRAME_SETUP.md new file mode 100644 index 0000000..2e8dd9b --- /dev/null +++ b/FRAME_SETUP.md @@ -0,0 +1,42 @@ +# Setting up a new photo frame + +## 1. Find the device + +```bash +adb devices -l +``` + +Note the device serial reported by `adb devices`. Use `-s ` in all commands below if multiple devices are connected. + +## 2. Install the APK + +```bash +adb install -r build/app/outputs/flutter-apk/app-release.apk +``` + +## 3. Disable the stock frame app (if any) + +```bash +adb shell pm list packages -s # find the stock app package name +adb shell pm disable-user --user 0 net.frameo.frame # replace with actual package +``` + +## 4. Set Open Photo Frame as default home (auto-starts on boot) + +```bash +adb shell cmd package set-home-activity io.github.micw.openphotoframe/.MainActivity +``` + +## 5. Launch now + +```bash +adb shell am start -n io.github.micw.openphotoframe/.MainActivity +``` + +## Build the APK + +```bash +cd android && ./gradlew assembleRelease +``` + +Output: `build/app/outputs/flutter-apk/app-release.apk` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d93a49d..f46052e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -36,6 +36,10 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } + lint { + disable += "Instantiatable" + } + defaultConfig { applicationId = "io.github.micw.openphotoframe" // You can update the following values to match your application needs. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 703aebf..6e2f4ee 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -89,6 +89,13 @@ + + + = Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Boot launch", + NotificationManager.IMPORTANCE_LOW + ) + getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + } +} diff --git a/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt b/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt index 815b19d..f50cc0d 100644 --- a/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt +++ b/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt @@ -1,42 +1,59 @@ package io.github.micw.openphotoframe +import android.app.AlarmManager +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.os.SystemClock import android.util.Log /** * BroadcastReceiver that starts the app when the device boots. * Only starts if autostart is enabled in app settings. + * + * Android 11+ blocks activity starts from background broadcast receivers + * (and even from foreground services started by them). Using AlarmManager + * with a PendingIntent works around this: the alarm fires via the system + * process, which is whitelisted for background activity starts. */ class BootReceiver : BroadcastReceiver() { companion object { private const val TAG = "BootReceiver" private const val PREFS_NAME = "FlutterSharedPreferences" private const val AUTOSTART_KEY = "flutter.autostart_on_boot" + private const val BOOT_ALARM_REQUEST_CODE = 9001 } override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED || - intent.action == "android.intent.action.QUICKBOOT_POWERON") { - - Log.d(TAG, "Boot completed received") - - // Check if autostart is enabled in shared preferences - val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val autostartEnabled = prefs.getBoolean(AUTOSTART_KEY, false) - - Log.d(TAG, "Autostart enabled: $autostartEnabled") - - if (autostartEnabled) { - Log.d(TAG, "Starting MainActivity") - val startIntent = Intent(context, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - context.startActivity(startIntent) - } + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != "android.intent.action.QUICKBOOT_POWERON") return + + Log.i(TAG, "Boot completed received") + + val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val autostartEnabled = prefs.getBoolean(AUTOSTART_KEY, false) + Log.i(TAG, "Autostart enabled: $autostartEnabled") + if (!autostartEnabled) return + + // Schedule MainActivity to start in ~3 seconds via AlarmManager. + // The alarm fires through the system process, bypassing Android 11's + // background activity start restriction (isBgStartWhitelisted). + val activityIntent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } + val pendingIntent = PendingIntent.getActivity( + context, + BOOT_ALARM_REQUEST_CODE, + activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val alarmManager = context.getSystemService(AlarmManager::class.java) + val triggerAt = SystemClock.elapsedRealtime() + 3_000L + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, pendingIntent + ) + Log.i(TAG, "Scheduled MainActivity launch via AlarmManager in 3s") } } diff --git a/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt b/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt index 15af24e..0fe3334 100644 --- a/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt +++ b/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt @@ -1,6 +1,8 @@ package io.github.micw.openphotoframe +import android.app.ActivityManager import android.app.AlarmManager +import android.os.Debug import android.app.PendingIntent import android.app.admin.DevicePolicyManager import android.content.ComponentName @@ -45,6 +47,9 @@ class ScreenControlHandler(private val context: Context) { } fun configureChannel(flutterEngine: FlutterEngine) { + val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + if (prefs.getBoolean("flutter.wifi_adb_enabled", true)) enableWifiAdb() + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "isDeviceAdminEnabled" -> { @@ -58,6 +63,35 @@ class ScreenControlHandler(private val context: Context) { openDeviceAdminSettings() result.success(null) } + "openWifiSettings" -> { + val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + result.success(null) + } + "openAndroidSettings" -> { + val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + result.success(null) + } + "openDeveloperSettings" -> { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + result.success(null) + } + "rebootDevice" -> { + try { + Runtime.getRuntime().exec(arrayOf("/system/xbin/su", "0", "/system/bin/reboot")) + result.success(true) + } catch (e: Exception) { + result.error("REBOOT_FAILED", e.message, null) + } + } "turnScreenOff" -> { val success = turnScreenOff() result.success(success) @@ -82,6 +116,12 @@ class ScreenControlHandler(private val context: Context) { "isScreenOn" -> { result.success(powerManager.isInteractive) } + "getMemoryInfo" -> { + result.success(getMemoryInfo()) + } + "getThermalInfo" -> { + result.success(getThermalInfo()) + } else -> { result.notImplemented() } @@ -212,6 +252,96 @@ class ScreenControlHandler(private val context: Context) { Log.d(TAG, "Cancelled scheduled wake-up") } + /** + * Returns Android memory stats for Prometheus metrics. + */ + private fun getMemoryInfo(): Map { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + am.getMemoryInfo(memInfo) + val rt = Runtime.getRuntime() + + // Read process CPU ticks from /proc/self/stat. + // Fields 14 (utime) and 15 (stime) are in jiffies (1/100s on Android). + var cpuJiffies = 0L + try { + val parts = java.io.File("/proc/self/stat").readText().trim().split(" ") + cpuJiffies = parts[13].toLong() + parts[14].toLong() + } catch (_: Exception) {} + + val result = mutableMapOf( + "system_avail_mem_bytes" to memInfo.availMem, + "system_total_mem_bytes" to memInfo.totalMem, + "system_low_mem_threshold_bytes" to memInfo.threshold, + "system_low_memory" to if (memInfo.lowMemory) 1L else 0L, + "jvm_total_bytes" to rt.totalMemory(), + "jvm_free_bytes" to rt.freeMemory(), + "jvm_max_bytes" to rt.maxMemory(), + "process_cpu_jiffies_total" to cpuJiffies, + "native_heap_allocated_bytes" to Debug.getNativeHeapAllocatedSize(), + "native_heap_size_bytes" to Debug.getNativeHeapSize(), + ) + + try { + val storageDir = context.getExternalFilesDir(null) ?: context.filesDir + val fs = android.os.StatFs(storageDir.absolutePath) + result["storage_total_bytes"] = fs.totalBytes + result["storage_free_bytes"] = fs.freeBytes + result["storage_avail_bytes"] = fs.availableBytes + } catch (_: Exception) {} + + return result + } + + /** + * Returns temperatures from all /sys/class/thermal/thermal_zone* sensors. + * Keys are the zone type strings (e.g. "cpu", "battery", "gpu"). + * Values are degrees Celsius as doubles. + * If multiple zones share the same type, they are suffixed _1, _2, etc. + */ + private fun getThermalInfo(): Map { + val result = mutableMapOf() + val thermalDir = java.io.File("/sys/class/thermal") + if (!thermalDir.exists()) return result + + thermalDir.listFiles() + ?.filter { it.name.startsWith("thermal_zone") } + ?.sortedBy { it.name } + ?.forEach { zone -> + try { + val type = java.io.File(zone, "type").readText().trim() + .replace(Regex("[^a-zA-Z0-9_]"), "_") + val raw = java.io.File(zone, "temp").readText().trim().toLong() + // Most Android devices report millidegrees; a few report degrees directly + val celsius = if (raw > 1000) raw / 1000.0 else raw.toDouble() + + // Skip sentinel values used for absent/disabled sensors + if (celsius < -200 || celsius > 200) return@forEach + + // Deduplicate keys if multiple zones share the same type + val key = if (!result.containsKey(type)) type else { + var i = 1 + while (result.containsKey("${type}_$i")) i++ + "${type}_$i" + } + result[key] = celsius + } catch (_: Exception) {} + } + + return result + } + + private fun enableWifiAdb() { + try { + Runtime.getRuntime().exec(arrayOf("/system/xbin/su", "0", "/system/bin/setprop", "service.adb.tcp.port", "5555")).waitFor() + Runtime.getRuntime().exec(arrayOf("/system/xbin/su", "0", "/system/bin/stop", "adbd")).waitFor() + Runtime.getRuntime().exec(arrayOf("/system/xbin/su", "0", "/system/bin/start", "adbd")) + Log.i(TAG, "WiFi ADB enabled on port 5555") + } catch (e: Exception) { + Log.d(TAG, "Could not enable WiFi ADB: ${e.message}") + } + } + /** * Wake up the screen immediately. */ diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/assets/config.json b/assets/config.json index 2a575a9..7ac21cb 100644 --- a/assets/config.json +++ b/assets/config.json @@ -10,6 +10,9 @@ "url": "", "folder_sync_mode": "all", "selected_folders": [] + }, + "icloud_album": { + "album_url": "" } } } diff --git a/lib/domain/interfaces/config_provider.dart b/lib/domain/interfaces/config_provider.dart index 5bec632..0077555 100644 --- a/lib/domain/interfaces/config_provider.dart +++ b/lib/domain/interfaces/config_provider.dart @@ -23,7 +23,10 @@ abstract class ConfigProvider extends ChangeNotifier { // Sync settings int get syncIntervalMinutes; // 0 = disabled, otherwise interval in minutes set syncIntervalMinutes(int value); - + + int get syncTimeoutSeconds; // Network timeout for sync requests (default 60) + set syncTimeoutSeconds(int value); + bool get deleteOrphanedFiles; // Delete local files not on server set deleteOrphanedFiles(bool value); @@ -37,6 +40,12 @@ abstract class ConfigProvider extends ChangeNotifier { bool get keepAliveEnabled; // Keep app running with foreground service (Android only) set keepAliveEnabled(bool value); + bool get wifiAdbEnabled; // Enable WiFi ADB on port 5555 via su on every boot (Android only) + set wifiAdbEnabled(bool value); + + String get webUiPassword; // Password for the web settings UI; empty disables authentication + set webUiPassword(String value); + // Auto-update settings (GitHub releases; opt-in, not for Play Store) bool get autoUpdateEnabled; // Periodically check GitHub for new releases set autoUpdateEnabled(bool value); diff --git a/lib/domain/models/photo_entry.dart b/lib/domain/models/photo_entry.dart index 6d9436b..10fb723 100644 --- a/lib/domain/models/photo_entry.dart +++ b/lib/domain/models/photo_entry.dart @@ -16,6 +16,7 @@ class PhotoEntry { // Runtime properties (not persisted) double weight = 0; DateTime? lastShown; + int displayCount = 0; PhotoEntry({ required this.file, diff --git a/lib/infrastructure/repositories/file_system_photo_repository.dart b/lib/infrastructure/repositories/file_system_photo_repository.dart index d1767f2..0a1e2ab 100644 --- a/lib/infrastructure/repositories/file_system_photo_repository.dart +++ b/lib/infrastructure/repositories/file_system_photo_repository.dart @@ -123,7 +123,7 @@ class FileSystemPhotoRepository implements PhotoRepository { final stat = await file.stat(); newPhotos.add(PhotoEntry( file: file, - date: stat.modified, // File date for shuffle algorithm + date: stat.modified, sizeBytes: stat.size, )); } diff --git a/lib/infrastructure/repositories/hybrid_photo_repository.dart b/lib/infrastructure/repositories/hybrid_photo_repository.dart index d60223d..28176e0 100644 --- a/lib/infrastructure/repositories/hybrid_photo_repository.dart +++ b/lib/infrastructure/repositories/hybrid_photo_repository.dart @@ -166,7 +166,7 @@ class HybridPhotoRepository implements PhotoRepository { final stat = await file.stat(); newPhotos.add(PhotoEntry( file: file, - date: stat.modified, // File date for shuffle algorithm + date: stat.modified, sizeBytes: stat.size, )); } @@ -289,8 +289,8 @@ class HybridPhotoRepository implements PhotoRepository { // For MediaStore: modifiedDateTime for shuffle, createDateTime as captureDate final entry = PhotoEntry( file: file, - date: asset.modifiedDateTime, // File date for shuffle algorithm - sizeBytes: asset.width * asset.height, // Approximate size from dimensions + date: asset.modifiedDateTime, + sizeBytes: asset.width * asset.height, ); // Set EXIF data from MediaStore (already available, no need for lazy loading) entry.setExifMetadata( diff --git a/lib/infrastructure/services/android_runtime_settings_sync.dart b/lib/infrastructure/services/android_runtime_settings_sync.dart index 1cd8c54..a673e2b 100644 --- a/lib/infrastructure/services/android_runtime_settings_sync.dart +++ b/lib/infrastructure/services/android_runtime_settings_sync.dart @@ -1,11 +1,14 @@ import '../../domain/interfaces/config_provider.dart'; import 'autostart_service.dart'; import 'keep_alive_service.dart'; +import 'wifi_adb_service.dart'; abstract class AndroidRuntimeSettingsWriter { Future setAutostartEnabled(bool enabled); Future setKeepAliveEnabled(bool enabled); + + Future setWifiAdbEnabled(bool enabled); } class SharedPreferencesAndroidRuntimeSettingsWriter @@ -19,6 +22,11 @@ class SharedPreferencesAndroidRuntimeSettingsWriter Future setKeepAliveEnabled(bool enabled) { return KeepAliveService.setEnabled(enabled); } + + @override + Future setWifiAdbEnabled(bool enabled) { + return WifiAdbService.setEnabled(enabled); + } } class AndroidRuntimeSettingsSync { @@ -30,5 +38,6 @@ class AndroidRuntimeSettingsSync { Future syncFromConfig(ConfigProvider configProvider) async { await _writer.setAutostartEnabled(configProvider.autostartOnBoot); await _writer.setKeepAliveEnabled(configProvider.keepAliveEnabled); + await _writer.setWifiAdbEnabled(configProvider.wifiAdbEnabled); } } \ No newline at end of file diff --git a/lib/infrastructure/services/geocoding_service.dart b/lib/infrastructure/services/geocoding_service.dart index f754a62..cc90cb7 100644 --- a/lib/infrastructure/services/geocoding_service.dart +++ b/lib/infrastructure/services/geocoding_service.dart @@ -18,9 +18,9 @@ class GeocodingService { /// User-Agent required by Nominatim usage policy static const String _userAgent = 'OpenPhotoFrame/1.0'; - /// Prefix for SharedPreferences keys - static const String _prefsPrefix = 'geocache_'; - static const String _prefsTsPrefix = 'geocache_ts_'; + /// Prefix for SharedPreferences keys (v2 = city-only format) + static const String _prefsPrefix = 'geocache_v2_'; + static const String _prefsTsPrefix = 'geocache_v2_ts_'; /// Maximum age for cache entries (3 months) static const Duration _maxCacheAge = Duration(days: 90); @@ -108,26 +108,13 @@ class GeocodingService { return null; } - // Build location string: City, State, Country - final parts = []; - - // City (try multiple fields) - final city = address['city'] ?? - address['town'] ?? - address['village'] ?? + // Build location string: city only + final city = address['city'] ?? + address['town'] ?? + address['village'] ?? address['municipality'] ?? address['county']; - if (city != null) parts.add(city.toString()); - - // State/Region - final state = address['state']; - if (state != null) parts.add(state.toString()); - - // Country - final country = address['country']; - if (country != null) parts.add(country.toString()); - - final result = parts.isNotEmpty ? parts.join(', ') : null; + final result = city?.toString(); await _cacheResult(cacheKey, result); _log.fine('Geocoded ($latitude, $longitude) → $result'); diff --git a/lib/infrastructure/services/icloud_album_source_config.dart b/lib/infrastructure/services/icloud_album_source_config.dart new file mode 100644 index 0000000..02f250b --- /dev/null +++ b/lib/infrastructure/services/icloud_album_source_config.dart @@ -0,0 +1,132 @@ +/// A single iCloud shared-album link with its extracted share token. +/// +/// Handles both URL styles: +/// https://www.icloud.com/sharedalbum/#TOKEN (token in fragment) +/// https://www.icloud.com/photos/TOKEN (token in last path segment) +/// +/// [url] may include an inline comment separated by whitespace + `#`, e.g.: +/// https://www.icloud.com/photos/TOKEN # Family holiday +/// The comment is stripped before URL parsing but preserved in storage and UI. +class IcloudAlbumLink { + final String url; + + const IcloudAlbumLink(this.url); + + // Strips trailing " # comment" while preserving "#TOKEN" fragments. + // A comment `#` is always preceded by whitespace; a URL fragment `#` is not. + String get _cleanUrl => url.replaceFirst(RegExp(r'\s+#.*$'), '').trim(); + + String get token { + final uri = Uri.tryParse(_cleanUrl); + if (uri == null) return ''; + if (uri.fragment.isNotEmpty) return uri.fragment; + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + return segments.isEmpty ? '' : segments.last; + } + + bool get isValid { + final uri = Uri.tryParse(_cleanUrl); + if (uri == null) return false; + return uri.host.contains('icloud.com') && token.isNotEmpty; + } +} + +/// Configuration for the iCloud shared-album source. +/// +/// Supports multiple shared albums: users enter one link per line. +/// Stored on disk either as the legacy single-`album_url` map (which is still +/// read and written for a single album, for backward compatibility) or as a +/// `album_urls` list. +class ICloudAlbumSourceConfig { + /// All configured album links (empty entries already filtered out). + final List albums; + + const ICloudAlbumSourceConfig({List? albums}) + : albums = albums ?? const []; + + /// Parses multi-line text (one album link per line) into links. + /// Lines starting with `#` are treated as full-line comments and skipped. + /// Inline comments (` # note`) are preserved in the stored URL and stripped + /// only when the URL is actually parsed. + static List parseLinks(String text) { + return text + .split(RegExp(r'\r?\n')) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty && !s.startsWith('#')) + .map(IcloudAlbumLink.new) + .toList(); + } + + /// Convenience constructor from raw multi-line link text. + factory ICloudAlbumSourceConfig.fromLinkText(String text) { + return ICloudAlbumSourceConfig(albums: parseLinks(text)); + } + + /// The primary album URL (first entry, or empty). Only used to keep the + /// legacy single-URL on-disk format stable; new code should use [albums]. + String get albumUrl => albums.isEmpty ? '' : albums.first.url.trim(); + + /// All album links, one per line (for UI text fields). + String get linksText => albums.map((a) => a.url.trim()).join('\n'); + + String get token { + final first = albums.where((a) => a.token.isNotEmpty).toList(); + return first.isEmpty ? '' : first.first.token; + } + + /// True if [albums] is non-empty and every link is a valid icloud.com + /// shared-album URL. This is the same strict check the config had before + /// multi-album support; it must be true before the source is activated + /// (e.g. by [ICloudAlbumSyncService]) — a non-empty list of garbage + /// links is NOT valid. + bool get isValid => albums.isNotEmpty && albums.every((a) => a.isValid); + + /// Non-empty links that are not valid icloud.com shared-album URLs. + List get invalidLinks => + albums.where((a) => !a.isValid).map((a) => a.url.trim()).toList(); + + factory ICloudAlbumSourceConfig.fromMap(Map config) { + final links = []; + + // New format: list of URLs. + final urls = config['album_urls']; + if (urls is List) { + for (final u in urls) { + links.add(u.toString().trim()); + } + } + + // Legacy (and current single-album) format: single URL string. + final single = config['album_url']; + if (single is String) { + final trimmed = single.trim(); + if (trimmed.isNotEmpty) links.add(trimmed); + } else if (single is List) { + // Be defensive about a list stored under the old key. + for (final u in single) { + links.add(u.toString().trim()); + } + } + + // Deduplicate, preserving order. + final seen = {}; + final cleaned = []; + for (final link in links) { + if (link.isNotEmpty && seen.add(link)) cleaned.add(link); + } + + return ICloudAlbumSourceConfig( + albums: cleaned.map(IcloudAlbumLink.new).toList(), + ); + } + + Map toMap() { + final urls = albums.map((a) => a.url.trim()).toList(); + if (urls.length <= 1) { + // Keep the legacy format on disk for a single album so existing + // consumers and older code keep working. + return {'album_url': urls.isEmpty ? '' : urls.first}; + } + return {'album_urls': urls}; + } +} diff --git a/lib/infrastructure/services/icloud_album_sync_service.dart b/lib/infrastructure/services/icloud_album_sync_service.dart new file mode 100644 index 0000000..496c2a8 --- /dev/null +++ b/lib/infrastructure/services/icloud_album_sync_service.dart @@ -0,0 +1,506 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; + +import '../../domain/interfaces/sync_provider.dart'; +import '../../domain/interfaces/storage_provider.dart'; +import 'icloud_album_source_config.dart'; + +class ICloudAlbumSyncException implements Exception { + ICloudAlbumSyncException(this.message, {this.cause}); + final String message; + final Object? cause; + + @override + String toString() => 'iCloud sync failed: $message${cause != null ? ' ($cause)' : ''}'; +} + +class ICloudAlbumSyncService implements SyncProvider { + static const Duration _downloadIdleTimeout = Duration(minutes: 15); + + ICloudAlbumSyncService({ + required ICloudAlbumSourceConfig config, + required StorageProvider storageProvider, + int timeoutSeconds = 60, + }) : _config = config, + _storageProvider = storageProvider, + _requestTimeout = Duration(seconds: timeoutSeconds); + + final ICloudAlbumSourceConfig _config; + final StorageProvider _storageProvider; + final Duration _requestTimeout; + final _log = Logger('ICloudAlbumSyncService'); + + @override + String get id => 'icloud_album'; + + @override + Future sync({ + bool deleteOrphanedFiles = false, + SyncProgressCallback? onProgress, + }) async { + _log.info( + 'Starting iCloud album sync (${_config.albums.length} album(s), ' + 'primary token: ${_config.token})'); + + final localDir = await _storageProvider.getPhotoDirectory(); + await localDir.create(recursive: true); + + // Step 1: get photo metadata from EVERY configured album. Each album may + // resolve to a different shard host, so the host is kept alongside its + // photos and all albums are combined afterwards. + final albumData = <(String, List>, String)>[]; + final fetchErrors = []; + var fetchedAlbums = 0; + for (var i = 0; i < _config.albums.length; i++) { + final album = _config.albums[i]; + if (!album.isValid) { + // Not a parseable icloud.com link with a token: skip the fetch + // entirely instead of firing a malformed request, and count it as + // unfetched so orphan cleanup stays safe (see step 6). + fetchErrors.add('Album ${i + 1} has an invalid or empty link: ' + '${album.url.trim()}'); + _log.warning('Skipping album ${i + 1} (${album.url.trim()}): ' + 'not a valid icloud.com shared-album link'); + continue; + } + try { + final result = await _fetchPhotoList(token: album.token); + fetchedAlbums++; + _log.info('Found ${result.$1.length} photos in iCloud album ' + '${i + 1}/${_config.albums.length} (token: ${album.token})'); + if (result.$1.isNotEmpty) { + albumData.add((album.token, result.$1, result.$2)); + } + } catch (e) { + fetchErrors.add(e); + _log.warning('Failed to fetch album ${i + 1} ' + '(${album.url.trim()}): $e'); + } + } + + if (albumData.isEmpty) { + if (fetchErrors.isNotEmpty) { + throw ICloudAlbumSyncException( + 'Failed to fetch photo list for all albums', + cause: fetchErrors.first); + } + return; + } + + // Step 2: build per-album guid list and checksum→guid mapping for the + // best derivative. webasseturls response is keyed by derivative checksum, + // NOT photoGuid. Photos shared across several albums are de-duplicated + // by guid. + final perAlbumGuids = >[]; + final checksumToGuid = {}; + final guidToChecksum = {}; + + for (final (_, albumPhotos, _) in albumData) { + final albumGuids = []; + for (final photo in albumPhotos) { + final guid = photo['photoGuid'] as String?; + if (guid == null || guid.isEmpty) continue; + final derivatives = photo['derivatives']; + if (derivatives is! Map) continue; + + // Iterate ALL derivative keys and pick the one with the largest max dimension. + // iCloud uses both standard keys ('342', '2048') and exact-pixel keys ('1537', etc.) + String? bestChecksum; + String? bestKey; + int bestMaxDim = 0; + int? bestW, bestH; + + for (final derivEntry in derivatives.entries) { + final deriv = derivEntry.value; + if (deriv is! Map) continue; + final checksum = deriv['checksum'] as String?; + if (checksum == null || checksum.isEmpty) continue; + final w = int.tryParse(deriv['width']?.toString() ?? '') ?? 0; + final h = int.tryParse(deriv['height']?.toString() ?? '') ?? 0; + final maxDim = w > h ? w : h; + if (maxDim > bestMaxDim) { + bestMaxDim = maxDim; + bestChecksum = checksum; + bestKey = derivEntry.key.toString(); + bestW = w; + bestH = h; + } + } + + if (bestChecksum != null) { + final checksum = bestChecksum; + checksumToGuid.putIfAbsent(checksum, () => guid); + guidToChecksum.putIfAbsent(guid, () => checksum); + albumGuids.add(guid); + _log.info('Photo ${guid.substring(0, 8)}: best derivative key=$bestKey (${bestW}x${bestH})'); + } + } + perAlbumGuids.add(albumGuids); + } + final guids = guidToChecksum.keys.toList(); + + // Step 3: get download URLs per album (each album has its own token and + // shard host), merging results across albums. Response keys are + // derivative checksums, which are mapped back to photoGuids for file + // naming. + final guidToUrl = {}; + final assetUrlErrors = []; + for (var i = 0; i < albumData.length; i++) { + final (token, _, host) = albumData[i]; + // Skip guids an earlier album already resolved a download URL for — + // re-requesting them here would be a wasted duplicate API call. + final requestedGuids = + perAlbumGuids[i].where((g) => !guidToUrl.containsKey(g)).toList(); + if (requestedGuids.isEmpty) continue; + try { + final rawUrls = + await _fetchAssetUrls(token: token, guids: requestedGuids, host: host); + _log.info('Got ${rawUrls.length} raw asset URLs for ' + '${requestedGuids.length} photos in album ${i + 1}'); + for (final entry in rawUrls.entries) { + final guid = checksumToGuid[entry.key]; + if (guid != null) guidToUrl.putIfAbsent(guid, () => entry.value); + } + } catch (e) { + assetUrlErrors.add(e); + _log.warning('Failed to fetch asset URLs for album ${i + 1} ' + '(token: $token): $e'); + } + } + + if (guidToUrl.isEmpty && assetUrlErrors.isNotEmpty) { + throw ICloudAlbumSyncException( + 'Failed to fetch download URLs for all albums', + cause: assetUrlErrors.first); + } + _log.info( + 'Matched ${guidToUrl.length} of ${guids.length} photos with download URLs'); + + // Step 4: determine which files need downloading. + // A .key sidecar file records the derivative checksum last downloaded. + // Re-download if the file is missing OR the checksum changed (better derivative available). + final pending = >[]; + for (final entry in guidToUrl.entries) { + final guid = entry.key; + final localFile = File('${localDir.path}/$guid.jpg'); + final keyFile = File('${localDir.path}/$guid.jpg.key'); + if (await localFile.exists() && await keyFile.exists()) { + final storedChecksum = await keyFile.readAsString(); + if (storedChecksum == guidToChecksum[guid]) continue; // already have best version + _log.info('Re-downloading upgraded derivative: $guid'); + await localFile.delete(); + } + pending.add(entry); + } + + _log.info('${pending.length} new photos to download'); + + // Step 5: download missing photos + final dio = Dio(); + for (var i = 0; i < pending.length; i++) { + final guid = pending[i].key; + final url = pending[i].value; + + onProgress?.call(SyncProgress( + completedFiles: i, + totalFiles: pending.length, + currentFileLabel: guid, + )); + + final partFile = File('${localDir.path}/$guid.jpg.part'); + final destFile = File('${localDir.path}/$guid.jpg'); + + final freeMb = await _storageFreeMb(localDir.path); + if (freeMb != null && freeMb < 100) { + _log.warning('Storage low (${freeMb.toStringAsFixed(1)} MB free) — ' + 'stopping download with ${pending.length - i} photos remaining'); + break; + } + + _log.info('Downloading ${i + 1}/${pending.length}: $guid'); + try { + await dio.download( + url, + partFile.path, + options: Options(receiveTimeout: _downloadIdleTimeout), + ); + await partFile.setLastModified(DateTime.now()); + await partFile.rename(destFile.path); + // Record which derivative checksum we downloaded for future upgrade checks + final keyFile = File('${localDir.path}/$guid.jpg.key'); + await keyFile.writeAsString(guidToChecksum[guid] ?? ''); + } catch (e) { + try { await partFile.delete(); } catch (_) {} + _log.warning('Failed to download $guid: $e'); + continue; + } + + onProgress?.call(SyncProgress( + completedFiles: i + 1, + totalFiles: pending.length, + currentFileLabel: guid, + )); + } + + // Step 6: delete orphaned files if requested (compare by photoGuid). + // Only safe when EVERY configured album was fetched successfully this + // run: if any album failed (network blip, invalid link), its + // already-downloaded photos would be missing from [guids] and wrongly + // deleted even though the album is still a live, configured source. + if (deleteOrphanedFiles) { + if (fetchedAlbums == _config.albums.length) { + await _deleteOrphans(localDir, guids.toSet()); + } else { + _log.warning('Skipping orphaned-file cleanup: only $fetchedAlbums of ' + '${_config.albums.length} configured album(s) were fetched ' + 'successfully this run, so files from failed albums would be ' + 'misidentified as orphans.'); + } + } + + _log.info('iCloud album sync complete'); + } + + // --------------------------------------------------------------------------- + // API: webstream — returns photo metadata + the final shard host + // --------------------------------------------------------------------------- + + Future<(List>, String)> _fetchPhotoList({ + required String token, + }) async { + final dio = Dio(); + var host = 'sharedstreams.icloud.com'; + + for (var attempt = 0; attempt < 2; attempt++) { + final url = 'https://$host/$token/sharedstreams/webstream'; + Response> response; + + try { + response = await dio.post>( + url, + data: '{"streamCtag":null}', + options: Options( + contentType: 'application/json', + receiveTimeout: _requestTimeout, + followRedirects: false, + validateStatus: (s) => s != null, + ), + ); + } on DioException catch (e) { + throw ICloudAlbumSyncException('Request failed', cause: e); + } + + if (response.statusCode == 330) { + final newHost = _extractRedirectHost(response); + if (newHost == null) { + throw ICloudAlbumSyncException('Got 330 redirect but no host in response'); + } + _log.info('iCloud redirect: $host → $newHost'); + host = newHost; + continue; + } + + if (response.statusCode != 200) { + throw ICloudAlbumSyncException('Unexpected HTTP ${response.statusCode}'); + } + + final data = response.data; + if (data == null) return (>[], host); + final photos = data['photos']; + if (photos is! List) return (>[], host); + + final all = photos.whereType>().toList(); + + // Log the first non-image item type we encounter so we can see the field + for (final p in all) { + final t = p['mediaAssetType'] ?? p['type'] ?? p['assetType']; + if (t != null && t.toString().toLowerCase() != 'image') { + _log.info('Skipping non-image asset: type=$t guid=${p['photoGuid']}'); + } + } + + // Filter to images only — videos and live photo components are excluded + final images = all.where((p) { + final t = (p['mediaAssetType'] ?? p['type'] ?? p['assetType']) + ?.toString() + .toLowerCase(); + return t == null || t == 'image'; + }).toList(); + + if (images.length < all.length) { + _log.info('Filtered ${all.length - images.length} non-image assets'); + } + + return (images, host); + } + + throw ICloudAlbumSyncException('Too many redirects'); + } + + // --------------------------------------------------------------------------- + // API: webasseturls — returns {photoGuid: downloadUrl} for the best derivative + // --------------------------------------------------------------------------- + + Future> _fetchAssetUrls({ + required String token, + required List guids, + required String host, + }) async { + final dio = Dio(); + final url = 'https://$host/$token/sharedstreams/webasseturls'; + + Response> response; + try { + response = await dio.post>( + url, + data: jsonEncode({'photoGuids': guids}), + options: Options( + contentType: 'application/json', + receiveTimeout: _requestTimeout, + validateStatus: (s) => s != null, + ), + ); + } on DioException catch (e) { + throw ICloudAlbumSyncException('webasseturls request failed', cause: e); + } + + if (response.statusCode != 200) { + throw ICloudAlbumSyncException( + 'webasseturls returned HTTP ${response.statusCode}'); + } + + final data = response.data; + if (data == null) return {}; + + // Log structure once so we can see what fields Apple returns + final items = data['items']; + if (items is Map && items.isNotEmpty) { + final firstItem = items.values.firstOrNull; + if (firstItem is Map) { + _log.info('webasseturls item keys: ${firstItem.keys.toList()}'); + final firstValue = firstItem.values.firstOrNull; + if (firstValue is Map) { + _log.info('webasseturls nested keys: ${firstValue.keys.toList()}'); + } + } + } + + return _extractAssetUrls(data); + } + + Map _extractAssetUrls(Map data) { + final result = {}; + final items = data['items']; + if (items is! Map) return result; + + for (final entry in items.entries) { + final guid = entry.key as String; + final item = entry.value; + if (item is! Map) continue; + + String? url; + + // Format A: items[guid] = {"2048": {"url": "...", ...}} (per-derivative map) + for (final key in ['2048', '1024', '512', '342', '256']) { + final deriv = item[key]; + if (deriv is Map) { + url = _buildUrl(deriv); + if (url != null) break; + } + } + + // Format B: items[guid] = {"url_location": "host", "url_path": "/path?sig=...", ...} + // Apple CDN splits host and signed path into separate fields + url ??= _buildUrl(item); + + if (url != null) result[guid] = url; + } + + return result; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Builds a full HTTPS URL from an API item map. + /// Handles three formats: + /// 1. {"url": "https://..."} — already full URL + /// 2. {"url_location": "host", "url_path": "/path?sig=..."} — Apple CDN split + /// 3. {"downloadURL": "..."} — alternate key name + String? _buildUrl(Map item) { + // Full URL in a single field + for (final key in ['url', 'downloadURL', 'download_url']) { + final v = item[key] as String?; + if (v != null && v.isNotEmpty) { + return v.startsWith('http') ? v : 'https://$v'; + } + } + // Apple split: url_location (host) + url_path (signed path) + final loc = item['url_location'] as String?; + final path = item['url_path'] as String?; + if (loc != null && loc.isNotEmpty && path != null && path.isNotEmpty) { + final host = loc.startsWith('http') ? loc : 'https://$loc'; + return '$host$path'; + } + return null; + } + + String? _extractRedirectHost(Response> response) { + final body = response.data; + if (body != null) { + final h = body['X-Apple-MMe-Host'] as String?; + if (h != null && h.isNotEmpty) return h; + } + return response.headers.map['x-apple-mme-host']?.firstOrNull; + } + + // Returns available storage in MB for the filesystem containing [path], or + // null if the check fails. Uses `df` (always present on Android via toybox). + static Future _storageFreeMb(String path) async { + try { + // -k forces 1K-block output; Android toybox df defaults to 1K-blocks but + // emits no suffix, which caused the no-suffix branch to treat KB as bytes. + final result = await Process.run('df', ['-k', path]); + final lines = (result.stdout as String).trim().split('\n'); + final parts = lines.last.trim().split(RegExp(r'\s+')); + if (parts.length < 4) return null; + final raw = parts[3]; + // With -k all values are in 1K-blocks; suffixes still handled defensively. + final multiplier = raw.endsWith('G') ? 1024.0 * 1024 + : raw.endsWith('M') ? 1024.0 + : raw.endsWith('K') ? 1.0 + : 1.0 / 1024; // 1K-blocks → MB + final value = double.tryParse(raw.replaceAll(RegExp(r'[GMKgmk]'), '')); + if (value == null) return null; + return value * multiplier; + } catch (_) { + return null; + } + } + + Future _deleteOrphans(Directory dir, Set remoteGuids) async { + final expectedJpg = remoteGuids.map((g) => '$g.jpg').toSet(); + final expectedKey = remoteGuids.map((g) => '$g.jpg.key').toSet(); + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is! File) continue; + final name = entity.path.split('/').last; + if (name.endsWith('.part')) continue; + if (name.endsWith('.jpg.key')) { + if (expectedKey.contains(name)) continue; + } else if (name.endsWith('.jpg') || name.endsWith('.jpeg')) { + if (expectedJpg.contains(name)) continue; + } else { + continue; + } + _log.info('Deleting orphaned file: $name'); + try { await entity.delete(); } catch (e) { + _log.warning('Failed to delete orphan $name: $e'); + } + } + } +} diff --git a/lib/infrastructure/services/json_config_service.dart b/lib/infrastructure/services/json_config_service.dart index 7b87361..9087059 100644 --- a/lib/infrastructure/services/json_config_service.dart +++ b/lib/infrastructure/services/json_config_service.dart @@ -223,6 +223,14 @@ class JsonConfigService extends ConfigProvider { } } + @override + String get webUiPassword => _config['web_ui_password'] ?? ''; + + @override + set webUiPassword(String value) { + _config['web_ui_password'] = value; + } + @override String get activeSourceType => _config['active_source'] ?? ''; @@ -272,12 +280,20 @@ class JsonConfigService extends ConfigProvider { // Sync settings @override int get syncIntervalMinutes => _config['sync_interval_minutes'] ?? 15; - + @override set syncIntervalMinutes(int value) { _config['sync_interval_minutes'] = value; } - + + @override + int get syncTimeoutSeconds => _config['sync_timeout_seconds'] ?? 60; + + @override + set syncTimeoutSeconds(int value) { + _config['sync_timeout_seconds'] = value; + } + @override bool get deleteOrphanedFiles => _config['delete_orphaned_files'] ?? true; @@ -314,6 +330,14 @@ class JsonConfigService extends ConfigProvider { _config['keep_alive_enabled'] = value; } + @override + bool get wifiAdbEnabled => _config['wifi_adb_enabled'] ?? true; + + @override + set wifiAdbEnabled(bool value) { + _config['wifi_adb_enabled'] = value; + } + // Auto-update settings @override bool get autoUpdateEnabled => _config['auto_update_enabled'] ?? false; diff --git a/lib/infrastructure/services/native_screen_control_service.dart b/lib/infrastructure/services/native_screen_control_service.dart index 5c899e4..0adb35a 100644 --- a/lib/infrastructure/services/native_screen_control_service.dart +++ b/lib/infrastructure/services/native_screen_control_service.dart @@ -116,7 +116,7 @@ class NativeScreenControlService { /// Check if the screen is currently on. static Future isScreenOn() async { if (!isSupported) return true; - + try { final result = await _channel.invokeMethod('isScreenOn'); return result ?? true; @@ -125,4 +125,44 @@ class NativeScreenControlService { return true; } } + + static Future openWifiSettings() async { + if (!isSupported) return; + await _channel.invokeMethod('openWifiSettings'); + } + + static Future openAndroidSettings() async { + if (!isSupported) return; + await _channel.invokeMethod('openAndroidSettings'); + } + + static Future openDeveloperSettings() async { + if (!isSupported) return; + await _channel.invokeMethod('openDeveloperSettings'); + } + + static Future rebootDevice() async { + if (!isSupported) return; + await _channel.invokeMethod('rebootDevice'); + } + + static Future> getMemoryInfo() async { + if (!isSupported) return {}; + try { + final result = await _channel.invokeMapMethod('getMemoryInfo'); + return result ?? {}; + } catch (e) { + return {}; + } + } + + static Future> getThermalInfo() async { + if (!isSupported) return {}; + try { + final result = await _channel.invokeMapMethod('getThermalInfo'); + return result ?? {}; + } catch (e) { + return {}; + } + } } diff --git a/lib/infrastructure/services/photo_service.dart b/lib/infrastructure/services/photo_service.dart index 32c9cc7..54eb863 100644 --- a/lib/infrastructure/services/photo_service.dart +++ b/lib/infrastructure/services/photo_service.dart @@ -58,6 +58,12 @@ class PhotoService extends ChangeNotifier { // Directory change subscription StreamSubscription? _directoryChangeSubscription; + // Metrics + int _photoDisplayCount = 0; + DateTime? _lastPhotoShownAt; + int _syncErrorCount = 0; + int _syncCount = 0; + PhotoService({ required SyncProviderFactory syncProviderFactory, required PlaylistStrategy playlistStrategy, @@ -76,6 +82,12 @@ class PhotoService extends ChangeNotifier { bool get isSyncing => _isSyncing; SyncProgress? get syncProgress => _syncProgress; SyncStatus? get syncStatus => _syncStatus; + int get photoCount => _repository.photos.length; + int get photoDisplayCount => _photoDisplayCount; + DateTime? get lastPhotoShownAt => _lastPhotoShownAt; + int get syncErrorCount => _syncErrorCount; + int get syncCount => _syncCount; + List get allPhotos => _repository.photos; Future initialize() async { if (_isInitialized) return; @@ -243,7 +255,7 @@ class PhotoService extends ChangeNotifier { // Save timestamp of successful sync _configProvider.lastSuccessfulSync = DateTime.now(); await _configProvider.save(); - + _syncCount++; _log.info("Sync completed successfully"); _updateSyncState(status: const SyncStatus.success()); // Repository watcher will pick up changes automatically @@ -252,6 +264,7 @@ class PhotoService extends ChangeNotifier { _log.info("Sync was cancelled"); _updateSyncState(status: const SyncStatus.cancelled()); } else { + _syncErrorCount++; _log.warning("Sync failed", e, stackTrace); _updateSyncState(status: SyncStatus.error(e)); rethrow; @@ -277,6 +290,9 @@ class PhotoService extends ChangeNotifier { if (photo != null) { photo.lastShown = DateTime.now(); + photo.displayCount++; + _lastPhotoShownAt = photo.lastShown; + _photoDisplayCount++; _history.add(photo); _historyIndex++; diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart new file mode 100644 index 0000000..1b61f63 --- /dev/null +++ b/lib/infrastructure/services/web_server_service.dart @@ -0,0 +1,1184 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/painting.dart'; + +import 'native_screen_control_service.dart'; + +import 'package:logging/logging.dart'; + +import '../../domain/interfaces/config_provider.dart'; +import '../../domain/interfaces/storage_provider.dart'; +import 'android_runtime_settings_sync.dart'; +import 'icloud_album_source_config.dart'; +import 'photo_service.dart'; + +class WebServerService { + static const int port = 8080; + + WebServerService({ + required ConfigProvider configProvider, + required PhotoService photoService, + required StorageProvider storageProvider, + }) : _config = configProvider, + _photoService = photoService, + _storageProvider = storageProvider; + + final ConfigProvider _config; + final PhotoService _photoService; + final StorageProvider _storageProvider; + final _log = Logger('WebServerService'); + + HttpServer? _server; + String? _lanIp; + StreamSubscription? _logSub; + final DateTime _startTime = DateTime.now(); + + static const int _maxLogEntries = 500; + final List> _logBuffer = []; + + Set _runningPackages = {}; + DateTime? _lastProcessScan; + bool _processScanInFlight = false; + + String? get lanIp => _lanIp; + String? get serverUrl => _lanIp != null ? 'http://$_lanIp:$port' : null; + + Future start() async { + // Capture all app log records into a rolling buffer + _logSub = Logger.root.onRecord.listen((r) { + final entry = { + 't': r.time.toIso8601String(), + 'l': r.level.name, + 'm': r.message, + }; + if (r.error != null) entry['e'] = r.error.toString(); + _logBuffer.add(entry); + if (_logBuffer.length > _maxLogEntries) _logBuffer.removeAt(0); + }); + + try { + _lanIp = await _findLanIp(); + _server = await HttpServer.bind(InternetAddress.anyIPv4, port); + _log.info('Web settings server on port $port (LAN: $_lanIp)'); + _handleRequests(); + } catch (e) { + _log.severe('Failed to start web server on port $port: $e'); + } + } + + Future stop() async { + await _logSub?.cancel(); + _logSub = null; + await _server?.close(force: true); + _server = null; + } + + void _handleRequests() { + _server?.listen((HttpRequest request) async { + try { + await _route(request); + } catch (e, st) { + _log.warning( + 'Error handling ${request.method} ${request.uri.path}', e, st); + _sendError(request, 500, 'Internal server error'); + } + }); + } + + Future _route(HttpRequest request) async { + final method = request.method; + final path = request.uri.path; + + request.response.headers + ..add('Access-Control-Allow-Origin', '*') + ..add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + ..add('Access-Control-Allow-Headers', 'Content-Type'); + + if (method == 'OPTIONS') { + request.response.statusCode = 204; + await request.response.close(); + return; + } + + // /metrics stays open so Prometheus keeps scraping without credentials. It + // exposes only counters and gauges — no album URLs, tokens or log lines. + // Everything else, including /api/config and /api/log, requires the password + // when one is set. + if (path != '/metrics' && !_isAuthorized(request)) { + _sendUnauthorized(request); + return; + } + + if (method == 'GET' && path == '/') { + _sendHtml(request, _settingsPage); + } else if (method == 'GET' && path == '/api/config') { + _sendJson(request, _buildConfigMap()); + } else if (method == 'POST' && path == '/api/config') { + await _handleSaveConfig(request); + } else if (method == 'GET' && path == '/api/status') { + _sendJson(request, _buildStatusMap()); + } else if (method == 'POST' && path == '/api/sync') { + _handleTriggerSync(request); + } else if (method == 'POST' && path == '/api/photos') { + await _handleUploadPhoto(request); + } else if (method == 'GET' && path == '/api/log') { + _sendJson(request, {'entries': List>.from(_logBuffer.reversed)}); + } else if (method == 'GET' && path == '/metrics') { + await _serveMetrics(request); + } else { + _sendError(request, 404, 'Not found'); + } + } + + // --------------------------------------------------------------------------- + // Authentication + // --------------------------------------------------------------------------- + + /// HTTP Basic auth against [ConfigProvider.webUiPassword]. Any username is + /// accepted — only the password is checked. An empty configured password + /// disables authentication entirely. + bool _isAuthorized(HttpRequest request) { + final expected = _config.webUiPassword; + if (expected.isEmpty) return true; + + final header = request.headers.value(HttpHeaders.authorizationHeader); + if (header == null || !header.toLowerCase().startsWith('basic ')) return false; + + String decoded; + try { + decoded = utf8.decode(base64.decode(header.substring(6).trim())); + } catch (_) { + return false; + } + final sep = decoded.indexOf(':'); + if (sep < 0) return false; + + return _constantTimeEquals(decoded.substring(sep + 1), expected); + } + + /// Compares without leaking length or position through timing. Not critical on + /// a LAN device, but cheap. + static bool _constantTimeEquals(String a, String b) { + final ab = utf8.encode(a); + final bb = utf8.encode(b); + var diff = ab.length ^ bb.length; + for (var i = 0; i < ab.length && i < bb.length; i++) { + diff |= ab[i] ^ bb[i]; + } + return diff == 0; + } + + void _sendUnauthorized(HttpRequest request) { + request.response + ..statusCode = HttpStatus.unauthorized + ..headers.set(HttpHeaders.wwwAuthenticateHeader, + 'Basic realm="OpenPhotoFrame", charset="UTF-8"') + ..headers.contentType = ContentType.text + ..write('Unauthorized'); + request.response.close(); + } + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + + Map _buildConfigMap() => { + // Source + 'active_source': _config.activeSourceType, + 'icloud_album': _config.getSourceConfig('icloud_album'), + 'nextcloud_link': _config.getSourceConfig('nextcloud_link'), + // Slideshow + 'slide_duration_seconds': _config.slideDurationSeconds, + 'transition_duration_ms': _config.transitionDurationMs, + 'blur_borders': _config.blurBorders, + // Sync + 'sync_interval_minutes': _config.syncIntervalMinutes, + 'sync_timeout_seconds': _config.syncTimeoutSeconds, + 'delete_orphaned_files': _config.deleteOrphanedFiles, + // Clock + 'show_clock': _config.showClock, + 'clock_size': _config.clockSize, + 'clock_position': _config.clockPosition, + // Photo info + 'show_photo_info': _config.showPhotoInfo, + 'photo_info_position': _config.photoInfoPosition, + 'photo_info_size': _config.photoInfoSize, + 'use_script_font': _config.useScriptFontForMetadata, + 'geocoding_enabled': _config.geocodingEnabled, + // Schedule + 'schedule_enabled': _config.scheduleEnabled, + 'day_start_hour': _config.dayStartHour, + 'day_start_minute': _config.dayStartMinute, + 'night_start_hour': _config.nightStartHour, + 'night_start_minute': _config.nightStartMinute, + 'fri_sat_night_start_hour': _config.fridaySaturdayNightStartHour, + 'fri_sat_night_start_minute': _config.fridaySaturdayNightStartMinute, + // Display + 'screen_orientation': _config.screenOrientation, + 'use_native_screen_off': _config.useNativeScreenOff, + // Android + 'autostart_on_boot': _config.autostartOnBoot, + 'keep_alive_enabled': _config.keepAliveEnabled, + 'wifi_adb_enabled': _config.wifiAdbEnabled, + 'web_ui_password': _config.webUiPassword, + 'auto_update_enabled': _config.autoUpdateEnabled, + }; + + Future _handleSaveConfig(HttpRequest request) async { + final body = await utf8.decodeStream(request); + Map u; + try { + u = jsonDecode(body) as Map; + } catch (_) { + _sendError(request, 400, 'Invalid JSON'); + return; + } + + void setBool(String key, void Function(bool) setter) { + if (u[key] is bool) setter(u[key] as bool); + } + + void setInt(String key, void Function(int) setter) { + if (u[key] is int) setter(u[key] as int); + } + + void setString(String key, void Function(String) setter) { + if (u[key] is String) setter(u[key] as String); + } + + // Source + setString('active_source', (v) => _config.activeSourceType = v); + if (u['icloud_album'] is Map) { + // Normalize through the config model so legacy `album_url` values and + // multi-line lists both end up in the canonical on-disk format. + _config.setSourceConfig('icloud_album', ICloudAlbumSourceConfig.fromMap( + Map.from(u['icloud_album'] as Map)).toMap()); + } + if (u['nextcloud_link'] is Map) { + _config.setSourceConfig( + 'nextcloud_link', Map.from(u['nextcloud_link'] as Map)); + } + // Slideshow + setInt('slide_duration_seconds', (v) => _config.slideDurationSeconds = v); + setInt('transition_duration_ms', (v) => _config.transitionDurationMs = v); + setBool('blur_borders', (v) => _config.blurBorders = v); + // Sync + setInt('sync_interval_minutes', (v) => _config.syncIntervalMinutes = v); + setInt('sync_timeout_seconds', (v) => _config.syncTimeoutSeconds = v); + setBool('delete_orphaned_files', (v) => _config.deleteOrphanedFiles = v); + // Clock + setBool('show_clock', (v) => _config.showClock = v); + setString('clock_size', (v) => _config.clockSize = v); + setString('clock_position', (v) => _config.clockPosition = v); + // Photo info + setBool('show_photo_info', (v) => _config.showPhotoInfo = v); + setString('photo_info_position', (v) => _config.photoInfoPosition = v); + setString('photo_info_size', (v) => _config.photoInfoSize = v); + setBool('use_script_font', (v) => _config.useScriptFontForMetadata = v); + setBool('geocoding_enabled', (v) => _config.geocodingEnabled = v); + // Schedule + setBool('schedule_enabled', (v) => _config.scheduleEnabled = v); + setInt('day_start_hour', (v) => _config.dayStartHour = v); + setInt('day_start_minute', (v) => _config.dayStartMinute = v); + setInt('night_start_hour', (v) => _config.nightStartHour = v); + setInt('night_start_minute', (v) => _config.nightStartMinute = v); + if (u.containsKey('fri_sat_night_start_hour')) { + _config.fridaySaturdayNightStartHour = + u['fri_sat_night_start_hour'] as int?; + } + if (u.containsKey('fri_sat_night_start_minute')) { + _config.fridaySaturdayNightStartMinute = + u['fri_sat_night_start_minute'] as int?; + } + // Display + setString('screen_orientation', (v) => _config.screenOrientation = v); + setBool('use_native_screen_off', (v) => _config.useNativeScreenOff = v); + // Android + setBool('autostart_on_boot', (v) => _config.autostartOnBoot = v); + setBool('keep_alive_enabled', (v) => _config.keepAliveEnabled = v); + setBool('wifi_adb_enabled', (v) => _config.wifiAdbEnabled = v); + setString('web_ui_password', (v) => _config.webUiPassword = v); + setBool('auto_update_enabled', (v) => _config.autoUpdateEnabled = v); + + await _config.save(); + // Sync Android runtime settings (SharedPreferences) so BootReceiver and + // KeepAliveService pick up changes without requiring an app restart. + await AndroidRuntimeSettingsSync().syncFromConfig(_config); + _sendJson(request, {'ok': true}); + } + + Map _buildStatusMap() { + final lastSync = _config.lastSuccessfulSync; + return { + 'photo_count': _photoService.photoCount, + 'is_syncing': _photoService.isSyncing, + 'last_sync_iso': lastSync?.toIso8601String(), + }; + } + + void _handleTriggerSync(HttpRequest request) { + _photoService.triggerSync().catchError((e) { + _log.warning('Web-triggered sync error: $e'); + }); + _sendJson(request, {'ok': true, 'message': 'Sync started'}); + } + + Future _handleUploadPhoto(HttpRequest request) async { + final filename = request.uri.queryParameters['filename'] ?? ''; + if (filename.isEmpty || filename.contains('/') || filename.contains('..')) { + _sendError(request, 400, 'Missing or invalid filename parameter'); + return; + } + + final dir = await _storageProvider.getPhotoDirectory(); + await dir.create(recursive: true); + + final bytes = await request.fold>( + [], + (acc, chunk) => acc..addAll(chunk), + ); + + await File('${dir.path}/$filename').writeAsBytes(bytes); + _log.info('Uploaded photo: $filename (${bytes.length} bytes)'); + _sendJson(request, {'ok': true, 'filename': filename}); + } + + // --------------------------------------------------------------------------- + // Prometheus metrics + // --------------------------------------------------------------------------- + + static bool _isPackageName(String name) { + if (!name.contains('.')) return false; + const knownTlds = {'com', 'net', 'io', 'org', 'android', 'uk', 'au', 'de', 'fr', 'co'}; + return knownTlds.contains(name.split('.').first); + } + + Future _refreshProcessStatus() async { + final now = DateTime.now(); + if (_lastProcessScan != null && now.difference(_lastProcessScan!).inSeconds < 60) return; + // Only ever run one scan at a time. Without this a slow scan lets every + // subsequent scrape start another one on top of it. + if (_processScanInFlight) return; + // Mark the attempt up front, not on success. If the scan hangs, the throttle + // above must still hold it off — otherwise the stuck scans pile up and the + // forks starve the device. + _lastProcessScan = now; + _processScanInFlight = true; + // /proc is mounted with hidepid=2 on Android — the app can only see its own PID + // without root. Use su to list process names as root. + // + // This deliberately runs a single `ps` rather than walking /proc/[0-9]*/cmdline: + // the old shell loop forked two processes per PID (~520 on this hardware) every + // scan, which was enough process churn to wedge a low-end frame. + try { + final result = await Process.run( + '/system/xbin/su', + ['0', 'ps', '-A', '-o', 'NAME'], + ).timeout(const Duration(seconds: 10)); + final found = {}; + for (final line in (result.stdout as String).split('\n')) { + final base = line.trim().split(':').first; + if (_isPackageName(base)) found.add(base); + } + _runningPackages = found; + } on TimeoutException { + _log.warning('Process scan timed out after 10s; keeping previous results'); + } catch (e) { + _log.fine('Process scan error: $e'); + } finally { + _processScanInFlight = false; + } + } + + Future _serveMetrics(HttpRequest request) async { + final buf = StringBuffer(); + + void gauge(String name, String help, num value) { + buf.writeln('# HELP $name $help'); + buf.writeln('# TYPE $name gauge'); + buf.writeln('$name $value'); + } + + void counter(String name, String help, num value) { + buf.writeln('# HELP $name $help'); + buf.writeln('# TYPE $name counter'); + buf.writeln('${name}_total $value'); + } + + final uptime = DateTime.now().difference(_startTime).inSeconds; + gauge('opf_uptime_seconds', 'Seconds since the app started', uptime); + + // Dart process memory + gauge('opf_process_rss_bytes', 'Resident set size of the app process', ProcessInfo.currentRss); + + // Flutter image cache + final cache = PaintingBinding.instance.imageCache; + gauge('opf_image_cache_bytes', 'Bytes currently used by Flutter image cache', cache.currentSizeBytes); + gauge('opf_image_cache_max_bytes', 'Maximum bytes allowed in Flutter image cache', cache.maximumSizeBytes); + gauge('opf_image_cache_count', 'Images currently in Flutter image cache', cache.currentSize); + gauge('opf_image_cache_max_count', 'Maximum images allowed in Flutter image cache', cache.maximumSize); + + // Photos + gauge('opf_photo_count', 'Photos available in local storage', _photoService.photoCount); + gauge('opf_sync_in_progress', '1 if a sync is currently running', _photoService.isSyncing ? 1 : 0); + counter('opf_sync', 'Successful syncs completed', _photoService.syncCount); + counter('opf_sync_error', 'Syncs that ended in an error', _photoService.syncErrorCount); + + final lastSync = _config.lastSuccessfulSync; + if (lastSync != null) { + gauge('opf_last_sync_timestamp_seconds', 'Unix timestamp of last successful sync', + lastSync.millisecondsSinceEpoch / 1000.0); + } + + // Slideshow + counter('opf_photo_display', 'Total photo display events since app start', _photoService.photoDisplayCount); + final lastShown = _photoService.lastPhotoShownAt; + if (lastShown != null) { + gauge('opf_last_photo_shown_timestamp_seconds', + 'Unix timestamp of the last time a photo was displayed', lastShown.millisecondsSinceEpoch / 1000.0); + } + + // Per-photo display counts and modification dates + final photos = _photoService.allPhotos; + if (photos.isNotEmpty) { + buf.writeln('# HELP opf_photo_display_count_total Times each photo has been shown since app start'); + buf.writeln('# TYPE opf_photo_display_count_total counter'); + for (final photo in photos) { + final name = photo.file.path.split('/').last.replaceAll('"', ''); + final freshnessTs = (photo.date.millisecondsSinceEpoch / 1000).floor(); + buf.writeln('opf_photo_display_count_total{photo="$name",freshness_date="$freshnessTs"} ${photo.displayCount}'); + } + } + + // Android / JVM memory (no-op on non-Android) + final androidMem = await NativeScreenControlService.getMemoryInfo(); + if (androidMem.isNotEmpty) { + gauge('opf_android_system_avail_mem_bytes', 'Available system memory reported by Android', androidMem['system_avail_mem_bytes'] ?? 0); + gauge('opf_android_system_total_mem_bytes', 'Total system memory reported by Android', androidMem['system_total_mem_bytes'] ?? 0); + gauge('opf_android_low_mem_threshold_bytes', 'Android OOM threshold — system is in low-memory state below this', androidMem['system_low_mem_threshold_bytes'] ?? 0); + gauge('opf_android_low_memory', '1 if Android reports the system is in a low-memory state', androidMem['system_low_memory'] ?? 0); + gauge('opf_jvm_total_bytes', 'JVM total heap size', androidMem['jvm_total_bytes'] ?? 0); + gauge('opf_jvm_free_bytes', 'JVM free heap', androidMem['jvm_free_bytes'] ?? 0); + gauge('opf_jvm_max_bytes', 'JVM maximum heap size', androidMem['jvm_max_bytes'] ?? 0); + gauge('opf_native_heap_allocated_bytes', 'Native heap bytes currently allocated', androidMem['native_heap_allocated_bytes'] ?? 0); + gauge('opf_native_heap_size_bytes', 'Native heap total size', androidMem['native_heap_size_bytes'] ?? 0); + final jiffies = androidMem['process_cpu_jiffies_total'] ?? 0; + // Jiffies run at 100Hz on Android → divide by 100 for CPU seconds + counter('opf_process_cpu_seconds', 'Total CPU time used by the app process', jiffies / 100.0); + } + + // Storage + if (androidMem.containsKey('storage_total_bytes')) { + gauge('opf_storage_total_bytes', 'Total bytes on the storage partition used by photos', androidMem['storage_total_bytes']!); + gauge('opf_storage_free_bytes', 'Free bytes on the storage partition', androidMem['storage_free_bytes'] ?? 0); + gauge('opf_storage_avail_bytes', 'Available bytes on the storage partition for app use', androidMem['storage_avail_bytes'] ?? 0); + } + + try { + final photoDir = await _storageProvider.getPhotoDirectory(); + if (await photoDir.exists()) { + var photoBytes = 0; + await for (final entity in photoDir.list()) { + if (entity is File) photoBytes += await entity.length(); + } + gauge('opf_photo_storage_bytes', 'Bytes used by downloaded photos on disk', photoBytes); + } + } catch (_) {} + + // Thermal zones + final thermal = await NativeScreenControlService.getThermalInfo(); + if (thermal.isNotEmpty) { + buf.writeln('# HELP opf_thermal_zone_celsius Temperature of each Android thermal zone'); + buf.writeln('# TYPE opf_thermal_zone_celsius gauge'); + for (final entry in thermal.entries) { + buf.writeln('opf_thermal_zone_celsius{zone="${entry.key}"} ${entry.value}'); + } + } + + // Running Android processes — refreshed at most once per 60s + await _refreshProcessStatus(); + if (_runningPackages.isNotEmpty) { + buf.writeln('# HELP opf_process_running 1 if the named Android process is currently running'); + buf.writeln('# TYPE opf_process_running gauge'); + for (final pkg in _runningPackages) { + buf.writeln('opf_process_running{process="$pkg"} 1'); + } + } + + // eMMC I/O errors from kernel ring buffer, with per-sector labels. + final ioErrors = await _fetchEmmcIoErrors(); + if (ioErrors != null) { + buf.writeln('# HELP opf_emmc_io_errors_total eMMC I/O error events reported by the kernel since last boot'); + buf.writeln('# TYPE opf_emmc_io_errors_total counter'); + if (ioErrors.isEmpty) { + buf.writeln('opf_emmc_io_errors_total{dev="none",sector="none"} 0'); + } else { + // Aggregate by (dev, sector) so each bad sector gets its own label set. + final counts = <(String, String), int>{}; + for (final e in ioErrors) { + final key = (e.dev, e.sector); + counts[key] = (counts[key] ?? 0) + 1; + } + for (final entry in counts.entries) { + final dev = entry.key.$1; + final sector = entry.key.$2; + buf.writeln('opf_emmc_io_errors_total{dev="$dev",sector="$sector"} ${entry.value}'); + } + } + } + + request.response + ..statusCode = 200 + ..headers.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + ..write(buf.toString()); + await request.response.close(); + } + + // --------------------------------------------------------------------------- + // eMMC health + // --------------------------------------------------------------------------- + + // Tracks which error lines have already been logged so we don't spam on every scrape. + static final Set _loggedEmmcErrors = {}; + static final _emmcLog = Logger('WebServerService.emmc'); + + // Returns parsed eMMC errors: list of (dev, sector, rawLine) records. + // Also logs new errors so sector numbers survive in logcat before the + // device potentially becomes unresponsive. + static Future?> _fetchEmmcIoErrors() async { + try { + final result = await Process.run('dmesg', []); + final lines = (result.stdout as String).split('\n'); + final errors = <({String dev, String sector, String raw})>[]; + for (final line in lines) { + if (!line.contains('I/O error') || !line.contains('mmcblk')) continue; + final devMatch = RegExp(r'dev\s+(\S+?)(?:,|$)').firstMatch(line); + final sectorMatch = RegExp(r'sector\s+(\d+)').firstMatch(line); + final dev = devMatch?.group(1) ?? 'unknown'; + final sector = sectorMatch?.group(1) ?? 'unknown'; + errors.add((dev: dev, sector: sector, raw: line.trim())); + if (_loggedEmmcErrors.add(line)) { + _emmcLog.warning('eMMC I/O error — dev=$dev sector=$sector: $line'); + } + } + return errors; + } catch (_) { + return null; + } + } + + // --------------------------------------------------------------------------- + // Response helpers + // --------------------------------------------------------------------------- + + void _sendJson(HttpRequest req, Map data) { + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(jsonEncode(data)); + req.response.close(); + } + + void _sendHtml(HttpRequest req, String html) { + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.html + ..write(html); + req.response.close(); + } + + void _sendError(HttpRequest req, int code, String message) { + req.response + ..statusCode = code + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': message})); + req.response.close(); + } + + // --------------------------------------------------------------------------- + // Network + // --------------------------------------------------------------------------- + + Future _findLanIp() async { + try { + final interfaces = await NetworkInterface.list( + includeLinkLocal: false, + type: InternetAddressType.IPv4, + ); + for (final iface in interfaces) { + for (final addr in iface.addresses) { + final ip = addr.address; + if (ip.startsWith('192.168.') || + ip.startsWith('10.') || + ip.startsWith('172.')) { + return ip; + } + } + } + } catch (e) { + _log.warning('Could not determine LAN IP: $e'); + } + return null; + } + + // --------------------------------------------------------------------------- + // Embedded HTML settings page + // --------------------------------------------------------------------------- + + static const String _settingsPage = r''' + + + + +Open Photo Frame — Settings + + + +

Open Photo Frame

+ +
+ Photos: + Last sync: + +
+ + +
+

Photo Source

+
+ + + +
+
+ +
+
+ +
+
+ + +
+

Slideshow

+
+ + + +
+
+ + + +
+
+ Blur bordersFill screen edges with blurred image + +
+
+ + +
+

Clock

+
+ Show clock + +
+ +
+ + +
+

Photo Information

+
+ Show photo infoDate and location overlay on slideshow + +
+ +
+ + +
+

Display Schedule

+
+ Day / Night scheduleTurn off display at night + +
+ +
+ + +
+

Screen

+ +
+ Native screen offUse Device Admin to fully turn off screen at night + +
+
+ + +
+

Sync

+
+ + + +
+
+ + + +
+
+ Delete photos removed from sourceRemove local files no longer on server + +
+
+ + +
+

Android

+
+ Start on bootAutomatically launch when device boots + +
+
+ Keep app runningPrevent app being stopped on low memory + +
+
+ WiFi ADBEnable ADB over WiFi on port 5555 on every boot (requires root) + +
+
+ Automatic updatesCheck GitHub for new versions + +
+
+ + +
+
+ + +
+

Actions

+
+ + +
+
+ + +
+

App Log

+
+ + + +
+
+
+ + +
+

Upload Photos

+
+ Drop photos here, or click to select files +
+ +
+
+ +
+ + +'''; +} diff --git a/lib/infrastructure/services/wifi_adb_service.dart b/lib/infrastructure/services/wifi_adb_service.dart new file mode 100644 index 0000000..1b8f6cd --- /dev/null +++ b/lib/infrastructure/services/wifi_adb_service.dart @@ -0,0 +1,12 @@ +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; + +class WifiAdbService { + static const String _wifiAdbKey = 'wifi_adb_enabled'; + + static Future setEnabled(bool enabled) async { + if (!Platform.isAndroid) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_wifiAdbKey, enabled); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9e33cda..05d2c09 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -34,6 +34,20 @@ "devicePhotosSubtitle": "Fotos vom Gerät anzeigen", "localFolder": "Lokaler Ordner", "localFolderSubtitle": "Fotos aus einem lokalen Ordner verwenden", + "icloudAlbum": "iCloud Geteiltes Album", + "icloudAlbumSubtitle": "Von Apple Photos geteiltem Album synchronisieren", + "icloudAlbumUrl": "iCloud geteilte Album-URLs", + "icloudAlbumUrlHint": "https://www.icloud.com/photos/…\nEin Link pro Zeile", + "icloudAlbumUrlInvalid": "Bitte eine gültige icloud.com/photos-URL eingeben", + "webSettingsAddress": "Web-Einstellungen verfügbar unter {url}", + "@webSettingsAddress": { + "placeholders": { + "url": { + "type": "String" + } + } + }, + "nextcloud": "Nextcloud", "nextcloudSubtitle": "Von Nextcloud öffentlichem Link synchronisieren", "loading": "Lädt...", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e69db4a..3f3cf31 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -34,6 +34,20 @@ "devicePhotosSubtitle": "Show photos from your device", "localFolder": "Local Folder", "localFolderSubtitle": "Use photos from a local folder", + "icloudAlbum": "iCloud Shared Album", + "icloudAlbumSubtitle": "Sync from Apple Photos shared album", + "icloudAlbumUrl": "iCloud Shared Album URL(s)", + "icloudAlbumUrlHint": "https://www.icloud.com/photos/…\nOne link per line", + "icloudAlbumUrlInvalid": "Enter a valid icloud.com/photos shared album URL", + "webSettingsAddress": "Web settings available at {url}", + "@webSettingsAddress": { + "placeholders": { + "url": { + "type": "String" + } + } + }, + "nextcloud": "Nextcloud", "nextcloudSubtitle": "Sync from Nextcloud public share link", "loading": "Loading...", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8b5ecc7..345d4ca 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -272,6 +272,42 @@ abstract class AppLocalizations { /// **'Use photos from a local folder'** String get localFolderSubtitle; + /// No description provided for @icloudAlbum. + /// + /// In en, this message translates to: + /// **'iCloud Shared Album'** + String get icloudAlbum; + + /// No description provided for @icloudAlbumSubtitle. + /// + /// In en, this message translates to: + /// **'Sync from Apple Photos shared album'** + String get icloudAlbumSubtitle; + + /// No description provided for @icloudAlbumUrl. + /// + /// In en, this message translates to: + /// **'iCloud Shared Album URL(s)'** + String get icloudAlbumUrl; + + /// No description provided for @icloudAlbumUrlHint. + /// + /// In en, this message translates to: + /// **'https://www.icloud.com/photos/…\nOne link per line'** + String get icloudAlbumUrlHint; + + /// No description provided for @icloudAlbumUrlInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid icloud.com/photos shared album URL'** + String get icloudAlbumUrlInvalid; + + /// No description provided for @webSettingsAddress. + /// + /// In en, this message translates to: + /// **'Web settings available at {url}'** + String webSettingsAddress(String url); + /// No description provided for @nextcloud. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b63fdeb..e2a5552 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -100,6 +100,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String get localFolderSubtitle => 'Fotos aus einem lokalen Ordner verwenden'; + @override + String get icloudAlbum => 'iCloud Geteiltes Album'; + + @override + String get icloudAlbumSubtitle => + 'Von Apple Photos geteiltem Album synchronisieren'; + + @override + String get icloudAlbumUrl => 'iCloud geteilte Album-URLs'; + + @override + String get icloudAlbumUrlHint => + 'https://www.icloud.com/photos/…\nEin Link pro Zeile'; + + @override + String get icloudAlbumUrlInvalid => + 'Bitte eine gültige icloud.com/photos-URL eingeben'; + + @override + String webSettingsAddress(String url) { + return 'Web-Einstellungen verfügbar unter $url'; + } + @override String get nextcloud => 'Nextcloud'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e617181..e01dcdf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -99,6 +99,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get localFolderSubtitle => 'Use photos from a local folder'; + @override + String get icloudAlbum => 'iCloud Shared Album'; + + @override + String get icloudAlbumSubtitle => 'Sync from Apple Photos shared album'; + + @override + String get icloudAlbumUrl => 'iCloud Shared Album URL(s)'; + + @override + String get icloudAlbumUrlHint => + 'https://www.icloud.com/photos/…\nOne link per line'; + + @override + String get icloudAlbumUrlInvalid => + 'Enter a valid icloud.com/photos shared album URL'; + + @override + String webSettingsAddress(String url) { + return 'Web settings available at $url'; + } + @override String get nextcloud => 'Nextcloud'; diff --git a/lib/main.dart b/lib/main.dart index 77c21fa..cae8c69 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,10 +15,13 @@ import 'domain/interfaces/display_controller.dart'; import 'infrastructure/services/app_initializer.dart'; import 'infrastructure/services/json_config_service.dart'; import 'infrastructure/services/exif_metadata_provider.dart'; +import 'infrastructure/services/icloud_album_source_config.dart'; +import 'infrastructure/services/icloud_album_sync_service.dart'; import 'infrastructure/services/webdav_source_config.dart'; import 'infrastructure/services/webdav_sync_service.dart'; import 'infrastructure/services/noop_sync_service.dart'; import 'infrastructure/services/photo_service.dart'; +import 'infrastructure/services/web_server_service.dart'; import 'infrastructure/services/local_storage_provider.dart'; import 'infrastructure/services/native_display_controller.dart'; import 'infrastructure/services/update_service.dart'; @@ -45,6 +48,12 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Slideshow never revisits a photo within hours, so the default 100MB image + // cache wastes RAM for zero benefit. Keep 2 slots (current + next) as a + // buffer for smooth transitions. + PaintingBinding.instance.imageCache.maximumSize = 2; + PaintingBinding.instance.imageCache.maximumSizeBytes = 20 * 1024 * 1024; + // Hide Status Bar and Navigation Bar (Immersive Mode) SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); @@ -123,6 +132,15 @@ class OpenPhotoFrameApp extends StatelessWidget { if (webdavConfig.url.isNotEmpty) { return WebDavSyncService.fromConfig(webdavConfig, storage); } + } else if (type == 'icloud_album') { + final icloudConfig = ICloudAlbumSourceConfig.fromMap(sourceConfig); + if (icloudConfig.isValid) { + return ICloudAlbumSyncService( + config: icloudConfig, + storageProvider: storage, + timeoutSeconds: config.syncTimeoutSeconds, + ); + } } return NoOpSyncService(); @@ -138,6 +156,21 @@ class OpenPhotoFrameApp extends StatelessWidget { }, ), + // Web settings UI — accessible from any browser on the LAN at port 8080 + Provider( + lazy: false, + create: (context) { + final service = WebServerService( + configProvider: context.read(), + photoService: context.read(), + storageProvider: context.read(), + ); + service.start(); + return service; + }, + dispose: (_, service) => service.stop(), + ), + // Opt-in GitHub self-updater (no-op unless enabled in settings) ChangeNotifierProvider( lazy: false, diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 09e1a82..f578465 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -15,6 +15,8 @@ import '../../infrastructure/repositories/hybrid_photo_repository.dart'; import '../../infrastructure/services/photo_service.dart'; import '../../infrastructure/services/native_updater_service.dart'; import '../../infrastructure/services/update_service.dart'; +import '../../infrastructure/services/icloud_album_source_config.dart'; +import '../../infrastructure/services/web_server_service.dart'; import '../../infrastructure/services/webdav_source_config.dart'; import '../../infrastructure/services/webdav_sync_service.dart'; import '../../infrastructure/services/autostart_service.dart'; @@ -43,7 +45,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse minute: 0, ); - late int _slideDurationMinutes; + late int _slideDurationSeconds; late double _transitionDurationSeconds; late bool _blurBorders; late String _syncType; @@ -52,7 +54,10 @@ class _SettingsScreenState extends State with WidgetsBindingObse late TextEditingController _webdavUserController; late TextEditingController _webdavPasswordController; late bool _webdavAllowInvalidCertificate; + late TextEditingController _icloudAlbumUrlController; + late TextEditingController _webUiPasswordController; late int _syncIntervalMinutes; + late int _syncTimeoutSeconds; late bool _deleteOrphanedFiles; late bool _autostartOnBoot; late bool _keepAliveEnabled; @@ -107,10 +112,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse String? _selectedAlbumId; bool _isLoadingAlbums = false; - // Track original values to detect changes - late String _originalSyncType; - late WebDavSourceConfig _originalWebDavSourceConfig; - @override void initState() { super.initState(); @@ -126,7 +127,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse SystemChrome.setPreferredOrientations(DeviceOrientation.values); final config = context.read(); - _slideDurationMinutes = (config.slideDurationSeconds / 60).round().clamp(1, 15); + _slideDurationSeconds = config.slideDurationSeconds.clamp(10, 3600); _transitionDurationSeconds = (config.transitionDurationMs / 1000.0).clamp(0.5, 5.0); _blurBorders = config.blurBorders; // Default sync type: app_folder on Android, local_folder on Desktop @@ -134,7 +135,10 @@ class _SettingsScreenState extends State with WidgetsBindingObse _syncType = config.activeSourceType.isEmpty ? defaultSyncType : config.activeSourceType; _localFolderPath = config.customPhotoPath ?? ''; _syncIntervalMinutes = config.syncIntervalMinutes; + _syncTimeoutSeconds = config.syncTimeoutSeconds; _deleteOrphanedFiles = config.deleteOrphanedFiles; + _webUiPasswordController = + TextEditingController(text: config.webUiPassword); _autostartOnBoot = config.autostartOnBoot; _keepAliveEnabled = config.keepAliveEnabled; _autoUpdateEnabled = config.autoUpdateEnabled; @@ -214,10 +218,13 @@ class _SettingsScreenState extends State with WidgetsBindingObse ) .toList(growable: false); - // Store original values for comparison on save - _originalSyncType = _syncType; - _originalWebDavSourceConfig = nextcloudConfig; - + final icloudConfig = ICloudAlbumSourceConfig.fromMap( + config.getSourceConfig('icloud_album'), + ); + _icloudAlbumUrlController = + TextEditingController(text: icloudConfig.linksText) + ..addListener(() => setState(() {})); + // Load saved album selection for device_photos mode final devicePhotosConfig = config.getSourceConfig('device_photos'); _selectedAlbumId = devicePhotosConfig['albumId'] as String?; @@ -286,6 +293,8 @@ class _SettingsScreenState extends State with WidgetsBindingObse _nextcloudUrlController.dispose(); _webdavUserController.dispose(); _webdavPasswordController.dispose(); + _icloudAlbumUrlController.dispose(); + _webUiPasswordController.dispose(); super.dispose(); } @@ -302,19 +311,13 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Detect if sync configuration changed final newNextcloudUrl = _nextcloudUrlController.text.trim(); + final newICloudAlbumLinks = _icloudAlbumUrlController.text; + final newICloudAlbumConfig = + ICloudAlbumSourceConfig.fromLinkText(newICloudAlbumLinks); final newWebDavSourceConfig = _buildWebDavSourceConfig( url: newNextcloudUrl, ); - final nextcloudConfigChanged = - !_nextcloudConfigsEqual(newWebDavSourceConfig, _originalWebDavSourceConfig); - final syncConfigChanged = - _syncType != _originalSyncType || - (_syncType == 'nextcloud_link' && nextcloudConfigChanged); - final newSyncSourceConfigured = syncConfigChanged && - _syncType == 'nextcloud_link' && - newNextcloudUrl.isNotEmpty; - - config.slideDurationSeconds = _slideDurationMinutes * 60; + config.slideDurationSeconds = _slideDurationSeconds; config.transitionDurationMs = (_transitionDurationSeconds * 1000).round(); config.blurBorders = _blurBorders; // app_folder and local_folder both use empty activeSourceType (no sync) @@ -328,7 +331,9 @@ class _SettingsScreenState extends State with WidgetsBindingObse config.customPhotoPath = null; } config.syncIntervalMinutes = _syncIntervalMinutes; + config.syncTimeoutSeconds = _syncTimeoutSeconds; config.deleteOrphanedFiles = _deleteOrphanedFiles; + config.webUiPassword = _webUiPasswordController.text.trim(); config.autostartOnBoot = _autostartOnBoot; config.keepAliveEnabled = _keepAliveEnabled; config.autoUpdateEnabled = _autoUpdateEnabled; @@ -359,28 +364,45 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Sync autostart setting to SharedPreferences for BootReceiver await AutostartService.setEnabled(_autostartOnBoot); - + // Sync keep alive setting to SharedPreferences for WakeReceiver await KeepAliveService.setEnabled(_keepAliveEnabled); + if (_syncType == 'nextcloud_link') { config.setSourceConfig('nextcloud_link', newWebDavSourceConfig.toMap()); } - + if (_syncType == 'icloud_album') { + config.setSourceConfig( + 'icloud_album', + newICloudAlbumConfig.toMap(), + ); + } + await config.save(); - - // If a new sync source was configured, trigger an immediate sync - // This runs in the background (fire-and-forget) so the user can continue - if (newSyncSourceConfigured) { + + // Trigger a sync whenever a source is configured, not just on first setup. + // Covers: URL changes, timeout changes, or simply tapping Save to force a retry. + final sourceIsConfigured = + (_syncType == 'nextcloud_link' && newNextcloudUrl.isNotEmpty) || + (_syncType == 'icloud_album' && + newICloudAlbumConfig.isValid); + if (sourceIsConfigured) { final photoService = context.read(); - // Don't await - let it run in the background - photoService.triggerSync(); + photoService.triggerSync(); // fire-and-forget } } @override Widget build(BuildContext context) { - return Scaffold( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + await _saveSettings(); + if (mounted) Navigator.of(context).pop(); + }, + child: Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.settings), leading: IconButton( @@ -394,9 +416,33 @@ class _SettingsScreenState extends State with WidgetsBindingObse body: ListView( padding: const EdgeInsets.all(16), children: [ + // === DEVICE IP / WEB SETTINGS URL === + if (Platform.isAndroid) + Builder(builder: (ctx) { + final url = ctx.read().serverUrl; + if (url == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.wifi, size: 18, color: Theme.of(ctx).colorScheme.primary), + const SizedBox(width: 10), + Text(url, style: TextStyle(fontSize: 13, color: Theme.of(ctx).colorScheme.primary, fontWeight: FontWeight.w500)), + ], + ), + ), + ); + }), + // === DEVICE ADMIN WARNING === if (Platform.isAndroid && _deviceAdminEnabled) ..._buildDeviceAdminWarning(), - + // === SLIDESHOW SETTINGS === _buildSectionHeader(AppLocalizations.of(context)!.sectionSlideshow), const SizedBox(height: 8), @@ -405,13 +451,20 @@ class _SettingsScreenState extends State with WidgetsBindingObse _buildSliderSetting( icon: Icons.timer, title: AppLocalizations.of(context)!.slideDuration, - value: _slideDurationMinutes.toDouble(), - min: 1, - max: 15, - divisions: 14, - unit: AppLocalizations.of(context)!.unitMinutes, + value: _slideDurationSeconds.toDouble(), + min: 10, + max: 3600, + divisions: 359, // 10-second steps + unit: '', + formatValue: (v) { + final s = v.round(); + if (s < 60) return '${s}s'; + final m = s ~/ 60; + final rem = s % 60; + return rem > 0 ? '${m}m ${rem}s' : '${m}m'; + }, onChanged: (value) { - setState(() => _slideDurationMinutes = value.round()); + setState(() => _slideDurationSeconds = value.round()); }, ), @@ -534,25 +587,59 @@ class _SettingsScreenState extends State with WidgetsBindingObse // === SYNC SETTINGS === _buildSectionHeader(AppLocalizations.of(context)!.sectionPhotoSource), const SizedBox(height: 8), - + + // Web settings server banner + Builder(builder: (ctx) { + final webServer = ctx.read(); + final url = webServer.serverUrl; + if (url == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: const Icon(Icons.open_in_browser), + title: Text( + AppLocalizations.of(ctx)!.webSettingsAddress(url), + style: const TextStyle(fontSize: 13), + ), + dense: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: Theme.of(ctx).colorScheme.outline.withOpacity(0.4)), + ), + ), + ); + }), + // Sync Type Selection (includes inline folder selector for local_folder) _buildSyncTypeSelector(), - + + // iCloud URL field (only visible if iCloud selected) + if (_syncType == 'icloud_album') ...[ + const SizedBox(height: 16), + _buildICloudAlbumSettings(), + ], + // Nextcloud URL (only visible if nextcloud selected) if (_syncType == 'nextcloud_link') ...[ const SizedBox(height: 16), _buildNextcloudSettings(), ], - - // Sync options (only visible if sync enabled - i.e. Nextcloud) - if (_syncType == 'nextcloud_link') ...[ + + // Sync options (iCloud or Nextcloud) + if (_syncType == 'icloud_album' || _syncType == 'nextcloud_link') ...[ const SizedBox(height: 16), // Sync Interval Slider _buildSyncIntervalSlider(), - + const SizedBox(height: 8), - + + // Sync Timeout Slider + _buildSyncTimeoutSlider(), + + const SizedBox(height: 8), + // Delete orphaned files checkbox SwitchListTile( title: Text(AppLocalizations.of(context)!.deleteOrphanedFiles), @@ -599,7 +686,58 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (Platform.isAndroid) ...[ _buildSectionHeader(AppLocalizations.of(context)!.sectionAndroid), const SizedBox(height: 8), - + + ListTile( + leading: const Icon(Icons.wifi), + title: const Text('Wi-Fi Settings'), + subtitle: const Text('Connect to a network'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => NativeScreenControlService.openWifiSettings(), + ), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Android Settings'), + subtitle: const Text('Open system settings'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => NativeScreenControlService.openAndroidSettings(), + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('Developer Options'), + subtitle: const Text('ADB, wireless debugging and more'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => NativeScreenControlService.openDeveloperSettings(), + ), + ListTile( + leading: const Icon(Icons.restart_alt), + title: const Text('Reboot Device'), + subtitle: const Text('Restart the device now'), + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reboot device?'), + content: const Text('The device will restart immediately.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Reboot'), + ), + ], + ), + ); + if (confirmed == true) { + await NativeScreenControlService.rebootDevice(); + } + }, + ), + + const SizedBox(height: 8), + SwitchListTile( title: Text(AppLocalizations.of(context)!.startOnBoot), subtitle: Text(AppLocalizations.of(context)!.startOnBootSubtitle), @@ -644,6 +782,28 @@ class _SettingsScreenState extends State with WidgetsBindingObse ), const SizedBox(height: 8), + + // On-device escape hatch: if the web UI password is forgotten, it can + // always be cleared here. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + controller: _webUiPasswordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Web UI password', + helperText: + 'Protects the web settings page and API. Leave blank to disable. ' + 'The /metrics endpoint stays open for Prometheus.', + helperMaxLines: 3, + prefixIcon: Icon(Icons.lock_outline), + border: OutlineInputBorder(), + ), + ), + ), + + const SizedBox(height: 8), + _buildAutoUpdateSection(), const SizedBox(height: 24), @@ -671,9 +831,10 @@ class _SettingsScreenState extends State with WidgetsBindingObse ), ], ), - ); + ), // end Scaffold (child of PopScope) + ); // end PopScope } - + Widget _buildAutoUpdateSection() { final l10n = AppLocalizations.of(context)!; final hintColor = Theme.of(context).colorScheme.onSurfaceVariant; @@ -841,6 +1002,15 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (_syncType == 'local_folder') _buildLocalFolderSelector(), ], + RadioListTile( + title: Text(AppLocalizations.of(context)!.icloudAlbum), + subtitle: Text(AppLocalizations.of(context)!.icloudAlbumSubtitle), + value: 'icloud_album', + groupValue: _syncType, + onChanged: (value) { + setState(() => _syncType = value!); + }, + ), RadioListTile( title: Text(AppLocalizations.of(context)!.nextcloud), subtitle: Text(AppLocalizations.of(context)!.nextcloudSubtitle), @@ -853,6 +1023,51 @@ class _SettingsScreenState extends State with WidgetsBindingObse ], ); } + + Widget _buildICloudAlbumSettings() { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.icloudAlbumUrl, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + TextField( + controller: _icloudAlbumUrlController, + maxLines: null, + decoration: InputDecoration( + hintText: l10n.icloudAlbumUrlHint, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 4), + Builder(builder: (ctx) { + final invalidLinks = ICloudAlbumSourceConfig + .fromLinkText(_icloudAlbumUrlController.text) + .invalidLinks; + if (invalidLinks.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.icloudAlbumUrlInvalid, + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 12)), + for (final link in invalidLinks) + Text(link, + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 11)), + ], + ); + }), + ], + ), + ); + } /// Android only: Show app folder path with warning Widget _buildAppFolderInfo() { @@ -1539,34 +1754,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse ); } - bool _nextcloudConfigsEqual( - WebDavSourceConfig left, - WebDavSourceConfig right, - ) { - final leftFolders = left.normalizedSelectedFolders.toList()..sort(); - final rightFolders = right.normalizedSelectedFolders.toList()..sort(); - - if (left.url != right.url || - left.authMode != right.authMode || - left.username != right.username || - left.password != right.password || - left.allowInvalidCertificate != right.allowInvalidCertificate || - left.folderSyncMode != right.folderSyncMode) { - return false; - } - - if (leftFolders.length != rightFolders.length) { - return false; - } - - for (var index = 0; index < leftFolders.length; index++) { - if (leftFolders[index] != rightFolders[index]) { - return false; - } - } - - return true; - } Widget _buildSyncIntervalSlider() { // Values: 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60 @@ -1605,6 +1792,36 @@ class _SettingsScreenState extends State with WidgetsBindingObse ); } + Widget _buildSyncTimeoutSlider() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.timer_outlined, size: 20), + const SizedBox(width: 12), + const Expanded(child: Text('Sync timeout')), + Text( + '$_syncTimeoutSeconds s', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Slider( + value: _syncTimeoutSeconds.toDouble(), + min: 15, + max: 120, + divisions: 7, // 15, 30, 45, 60, 75, 90, 105, 120 + onChanged: (value) { + setState(() => _syncTimeoutSeconds = (value / 15).round() * 15); + }, + ), + ], + ); + } + Widget _buildSyncNowButton() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -2319,6 +2536,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse const Spacer(), SegmentedButton( segments: const [ + ButtonSegment(value: 'xsmall', label: Text('XS')), ButtonSegment(value: 'small', label: Text('S')), ButtonSegment(value: 'medium', label: Text('M')), ButtonSegment(value: 'large', label: Text('L')), diff --git a/lib/ui/screens/slideshow_screen.dart b/lib/ui/screens/slideshow_screen.dart index 2d84a2f..653c407 100644 --- a/lib/ui/screens/slideshow_screen.dart +++ b/lib/ui/screens/slideshow_screen.dart @@ -802,13 +802,16 @@ class _SlideshowScreenState extends State with TickerProviderSt config.addListener(_onConfigChanged); } - /// Handle config changes for Keep Alive service + /// Handle config changes void _onConfigChanged() { + if (!mounted) return; final config = context.read(); - final shouldRun = config.keepAliveEnabled; - - // Start or stop service based on config - if (shouldRun) { + + // Restart timer so any slide duration change takes effect immediately + _startTimer(); + + // Start or stop keep alive service + if (config.keepAliveEnabled) { KeepAliveService.startService(); } else { KeepAliveService.stopService(); diff --git a/lib/ui/widgets/photo_info_overlay.dart b/lib/ui/widgets/photo_info_overlay.dart index af266ae..ee822c8 100644 --- a/lib/ui/widgets/photo_info_overlay.dart +++ b/lib/ui/widgets/photo_info_overlay.dart @@ -72,17 +72,19 @@ class PhotoInfoOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - // Build info lines + final dateStr = photo.captureDate != null ? _formatDate(photo.captureDate!) : null; + final cityStr = (locationName != null && locationName!.isNotEmpty) ? locationName : null; + + // For bottom positions: city on top, date on bottom (reads naturally upward). + // For top positions: date on top, city below. + final bool bottomPosition = position == 'bottomRight' || position == 'bottomLeft'; final List infoLines = []; - - // Add capture date only if available from EXIF (no fallback to file date) - if (photo.captureDate != null) { - infoLines.add(_formatDate(photo.captureDate!)); - } - - // Add location if available - if (locationName != null && locationName!.isNotEmpty) { - infoLines.add(locationName!); + if (bottomPosition) { + if (cityStr != null) infoLines.add(cityStr); + if (dateStr != null) infoLines.add(dateStr); + } else { + if (dateStr != null) infoLines.add(dateStr); + if (cityStr != null) infoLines.add(cityStr); } if (infoLines.isEmpty) { @@ -109,8 +111,10 @@ class PhotoInfoOverlay extends StatelessWidget { case 'medium': return 39; case 'small': - default: return 30; + case 'xsmall': + default: + return 22; } } diff --git a/pubspec.lock b/pubspec.lock index d7c4fb2..cd9cc46 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,10 +337,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -702,10 +702,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/scripts/frame.env.example b/scripts/frame.env.example new file mode 100644 index 0000000..e6aea54 --- /dev/null +++ b/scripts/frame.env.example @@ -0,0 +1,8 @@ +# Copy to frame.env and fill in. frame.env is gitignored so the device's +# serial and LAN address never reach the public repository. +# +# cp scripts/frame.env.example scripts/frame.env +# +# Find the serial with `adb devices`; the IP is on the frame's settings screen. +FRAME_SERIAL=XXXXXXXXXXXXXXXXX +FRAME_IP=192.168.0.0 diff --git a/scripts/px110.sh b/scripts/px110.sh new file mode 100755 index 0000000..8685810 --- /dev/null +++ b/scripts/px110.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# Setup and health check for the Pexar Frame PX-110 (MediaTek MT8167). +# +# Idempotent — safe to re-run. Connects over Wi-Fi (falling back to USB and +# re-enabling TCP mode), repairs the stock-service configuration, pins the app as +# the default launcher, and prints a health summary. +# +# Usage: +# ./scripts/px110.sh # connect, repair config, report health +# ./scripts/px110.sh --health # report only, change nothing +# ./scripts/px110.sh --install # also install the release APK +# +# The device's serial and LAN address are read from scripts/frame.env, which is +# gitignored so they stay out of this public repository: +# +# cp scripts/frame.env.example scripts/frame.env # then fill it in +# +# Environment variables win over the file, e.g. FRAME_IP=10.0.0.5 ./scripts/px110.sh +# +# See FRAMEO.md for why each step exists. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +[ -f "${HERE}/frame.env" ] && . "${HERE}/frame.env" + +ADB="${ADB:-$HOME/Library/Android/sdk/platform-tools/adb}" +SERIAL="${FRAME_SERIAL:-}" +IP="${FRAME_IP:-}" + +if [ -z "${SERIAL}" ] || [ -z "${IP}" ]; then + echo "FRAME_SERIAL and FRAME_IP are not set." >&2 + echo "Create ${HERE}/frame.env from frame.env.example, or pass them as env vars." >&2 + exit 2 +fi +PORT=5555 +PKG=io.github.micw.openphotoframe +APK="build/app/outputs/flutter-apk/app-release.apk" +PROINFO=/dev/block/platform/soc/11120000.mmc/by-name/proinfo + +# com.adups.fota is android:persistent="true". Disabling the *package* makes +# ActivityManager respawn it ~17x/minute forever — see the outage notes in +# FRAMEO.md. Disable these components instead and leave the package enabled. +ADUPS_COMPONENTS=( + .receiver.MyReceiver + .service.FcmService + .GoogleOtaClient + com.google.firebase.iid.FirebaseInstanceIdReceiver + com.google.firebase.messaging.FirebaseMessagingService + .activity.GdprActivity +) +# Not persistent, so disabling the whole package is safe. +DISABLE_PACKAGES=(com.DeviceTest net.frameo.frame) + +HEALTH_ONLY=0 +DO_INSTALL=0 +for arg in "$@"; do + case "$arg" in + --health) HEALTH_ONLY=1 ;; + --install) DO_INSTALL=1 ;; + -h|--help) sed -n '2,17p' "$0" | sed 's/^# \?//'; exit 0 ;; + *) echo "unknown option: $arg (try --help)" >&2; exit 2 ;; + esac +done + +DEV="" +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } +bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } + +sh_() { "${ADB}" -s "${DEV}" shell "$@" 2>/dev/null | tr -d '\r'; } +su_() { "${ADB}" -s "${DEV}" shell "/system/xbin/su 0 $*" 2>/dev/null | tr -d '\r'; } + +# --- connect ---------------------------------------------------------------- + +step "Connecting" +"${ADB}" connect "${IP}:${PORT}" >/dev/null 2>&1 +if "${ADB}" -s "${IP}:${PORT}" shell true >/dev/null 2>&1; then + DEV="${IP}:${PORT}" + ok "Wi-Fi ADB at ${DEV}" +elif "${ADB}" -s "${SERIAL}" shell true >/dev/null 2>&1; then + DEV="${SERIAL}" + warn "Wi-Fi unavailable; using USB. Re-enabling TCP mode..." + "${ADB}" -s "${SERIAL}" tcpip "${PORT}" >/dev/null 2>&1 + sleep 4 + conn=$("${ADB}" connect "${IP}:${PORT}" 2>&1) + if [ "${conn#*connected}" != "${conn}" ]; then + DEV="${IP}:${PORT}" + ok "Wi-Fi ADB re-enabled at ${DEV} — you can unplug USB" + else + warn "Still on USB (${SERIAL}); USB on this frame is unreliable" + fi +else + bad "No device. Plug in USB, or check the frame is on the network at ${IP}." + exit 1 +fi + +if [ "$(su_ id -u)" != "0" ]; then + bad "Root unavailable via /system/xbin/su — cannot continue" + exit 1 +fi +ok "root via /system/xbin/su" + +# --- repair ----------------------------------------------------------------- + +if [ "${HEALTH_ONLY}" -eq 0 ]; then + step "Stock services" + + state=$(sh_ "dumpsys package com.adups.fota" | grep -m1 -oE "enabled=[0-9]+" | cut -d= -f2) + if [ "${state}" = "3" ]; then + warn "com.adups.fota is disabled-user — this causes the respawn loop. Re-enabling." + su_ pm enable com.adups.fota >/dev/null + ok "com.adups.fota re-enabled" + else + ok "com.adups.fota package enabled (state ${state:-?}) — no respawn loop" + fi + + for c in "${ADUPS_COMPONENTS[@]}"; do + su_ "pm disable --user 0 com.adups.fota/${c}" >/dev/null + done + # Count the indented identifier lines that follow the disabledComponents header, + # stopping at the first line that is not one. + n=$(sh_ "dumpsys package com.adups.fota" \ + | awk '/disabledComponents:/{f=1;next} f && /^ +[A-Za-z0-9_.$]+$/{c++;next} f{exit} END{print c+0}') + ok "adups components disabled (${n} listed)" + + for p in "${DISABLE_PACKAGES[@]}"; do + su_ "pm disable-user --user 0 ${p}" >/dev/null + su_ "am force-stop ${p}" >/dev/null + ok "${p} disabled" + done + + step "Default launcher" + su_ "cmd package set-home-activity ${PKG}/.MainActivity" >/dev/null + home=$(sh_ "cmd package resolve-activity -a android.intent.action.MAIN \ + -c android.intent.category.HOME --brief" | grep "/" | tail -1 | tr -d ' ') + case "${home}" in + ${PKG}/*) ok "HOME is ${home}" ;; + *) warn "HOME resolves to '${home:-nothing}' — expected ${PKG}" ;; + esac +fi + +# --- install ---------------------------------------------------------------- + +if [ "${DO_INSTALL}" -eq 1 ]; then + step "Installing ${APK}" + if [ ! -f "${APK}" ]; then + bad "Not found. Build first: flutter build apk --release" + exit 1 + fi + abis=$(unzip -l "${APK}" | grep -o 'lib/[^/]*' | sort -u) + if [ "${abis#*armeabi-v7a}" = "${abis}" ]; then + bad "APK has no armeabi-v7a slice — this frame is 32-bit only and cannot run it" + bad "found: $(echo "${abis}" | tr '\n' ' ')" + exit 1 + fi + ok "armeabi-v7a slice present" + out=$("${ADB}" -s "${DEV}" install -r -g "${APK}" 2>&1) + if [ "${out#*Success}" != "${out}" ]; then + ok "installed" + sh_ "monkey -p ${PKG} -c android.intent.category.LAUNCHER 1" >/dev/null + # On startup the app re-runs enableWifiAdb(), which does `stop adbd; start + # adbd` — that drops this very session. Wait for adbd to come back, or the + # health checks below all read as empty failures. + if [ "${DEV}" = "${IP}:${PORT}" ]; then + sleep 10 + for _ in 1 2 3 4 5 6; do + "${ADB}" connect "${IP}:${PORT}" >/dev/null 2>&1 + "${ADB}" -s "${DEV}" shell true >/dev/null 2>&1 && break + sleep 5 + done + fi + ok "reconnected after adbd restart" + else + bad "install failed — check ro.vendor.custom_recover (see FRAMEO.md)" + exit 1 + fi +fi + +# --- health ----------------------------------------------------------------- + +step "Health" + +lock=$(sh_ getprop ro.vendor.custom_recover) +[ "${lock}" = "0" ] && ok "install lock clear (ro.vendor.custom_recover=0)" \ + || bad "install lock ON (=${lock}) — sideloading blocked, see FRAMEO.md" + +up=$(sh_ "cut -d. -f1 /proc/uptime") +printf ' device uptime %ss, boot reason: %s\n' "${up}" "$(sh_ getprop sys.boot.reason)" + +appup=$(curl -s -m 8 "http://${IP}:8080/metrics" 2>/dev/null | awk '/^opf_uptime_seconds /{print $2}') +if [ -n "${appup}" ]; then + ok "app serving metrics, uptime ${appup}s" +else + bad "app web server not responding on ${IP}:8080" +fi + +focus=$(sh_ "dumpsys window" | grep -m1 mCurrentFocus) +case "${focus}" in + *${PKG}*) ok "app in foreground" ;; + *) warn "foreground: ${focus:-unknown}" ;; +esac + +# The respawn loop is the thing most likely to come back. Zero is the only good answer. +deaths=$(su_ "logcat -d" | grep -c "com.adups.fota.*has died") +[ "${deaths}" -eq 0 ] && ok "no adups respawns in the log buffer" \ + || bad "${deaths} adups respawns in buffer — respawn loop is back" + +# Orphaned helpers from the old /proc-walk bug; should be none. +orph=$(su_ 'sh -c "ps -A -o NAME | grep -cE \"^(tr|head)$\""') +[ "${orph:-0}" -eq 0 ] && ok "no orphaned scan helpers" \ + || warn "${orph} orphaned tr/head processes" + +# Load average is meaningless here (~6 MTK kernel threads sit permanently in D +# state and each counts toward it). Use the idle/sys split instead. +sh_ "top -n 1 -b" | sed -n '/%cpu/p' | head -1 | sed 's/^/ /' +sh_ "cat /sys/class/thermal/thermal_zone0/temp" | awk '{printf " cpu temp %.1fC\n", $1/1000}' + +alarms=$(sh_ "dumpsys alarm" | grep -ciE "adups|net\.frameo") +[ "${alarms}" -eq 0 ] && ok "no wakeup alarms from disabled packages" \ + || warn "${alarms} alarm lines from adups/frameo (harmless if receivers are disabled)" + +printf '\nDone. Shell: %s -s %s shell\n' "${ADB}" "${DEV}"