From 287de9f86a86ac99e7644401dd127cb7f6573f7e Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Sun, 9 Aug 2026 16:36:22 +1000 Subject: [PATCH 1/7] Add iCloud shared album sync, web settings UI, and photo frame improvements Features: - iCloud shared album sync: two-step API (webstream + webasseturls), handles 330 redirects, picks best derivative by pixel dimensions (handles iPhone 15 Pro key '2049'), filters videos, checksum sidecar files for upgrade detection - Embedded web settings server on port 8080 with full settings form and sync controls; web UI save now also writes to SharedPreferences so BootReceiver picks up changes without requiring an app restart - Geocoding now shows city only (not City, State, Country); cache key bumped to v2 to invalidate old entries - Photo info overlay: added XSmall (22px) text size option (default); for bottom positions city is shown above date; for top positions date stays on top - Slide duration change now takes effect immediately without waiting for the current timer to expire - Boot autostart: BootReceiver uses AlarmManager (setExactAndAllowWhileIdle) to schedule MainActivity launch 3s after boot; logs upgraded to Log.i so they appear in release builds --- android/app/build.gradle.kts | 4 + android/app/src/main/AndroidManifest.xml | 7 + .../micw/openphotoframe/BootLaunchService.kt | 66 ++ .../micw/openphotoframe/BootReceiver.kt | 55 +- android/gradle.properties | 4 + assets/config.json | 3 + .../services/geocoding_service.dart | 29 +- .../services/icloud_album_source_config.dart | 34 + .../services/icloud_album_sync_service.dart | 390 ++++++++ .../services/photo_service.dart | 1 + .../services/web_server_service.dart | 852 ++++++++++++++++++ lib/l10n/app_de.arb | 14 + lib/l10n/app_en.arb | 14 + lib/l10n/app_localizations.dart | 36 + lib/l10n/app_localizations_de.dart | 22 + lib/l10n/app_localizations_en.dart | 21 + lib/main.dart | 26 + lib/ui/screens/settings_screen.dart | 155 +++- lib/ui/screens/slideshow_screen.dart | 13 +- lib/ui/widgets/photo_info_overlay.dart | 26 +- pubspec.lock | 8 +- 21 files changed, 1698 insertions(+), 82 deletions(-) create mode 100644 android/app/src/main/kotlin/io/github/micw/openphotoframe/BootLaunchService.kt create mode 100644 lib/infrastructure/services/icloud_album_source_config.dart create mode 100644 lib/infrastructure/services/icloud_album_sync_service.dart create mode 100644 lib/infrastructure/services/web_server_service.dart 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/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/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..25570ce --- /dev/null +++ b/lib/infrastructure/services/icloud_album_source_config.dart @@ -0,0 +1,34 @@ +class ICloudAlbumSourceConfig { + final String albumUrl; + + const ICloudAlbumSourceConfig({this.albumUrl = ''}); + + /// Extracts the 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) + String get token { + final trimmed = albumUrl.trim(); + if (trimmed.isEmpty) return ''; + final uri = Uri.tryParse(trimmed); + 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 { + if (albumUrl.trim().isEmpty) return false; + final uri = Uri.tryParse(albumUrl.trim()); + if (uri == null) return false; + return uri.host.contains('icloud.com') && token.isNotEmpty; + } + + factory ICloudAlbumSourceConfig.fromMap(Map config) { + return ICloudAlbumSourceConfig( + albumUrl: (config['album_url'] as String? ?? '').trim(), + ); + } + + Map toMap() => {'album_url': albumUrl.trim()}; +} 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..8909d90 --- /dev/null +++ b/lib/infrastructure/services/icloud_album_sync_service.dart @@ -0,0 +1,390 @@ +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 _requestTimeout = Duration(seconds: 30); + static const Duration _downloadIdleTimeout = Duration(minutes: 15); + + ICloudAlbumSyncService({ + required ICloudAlbumSourceConfig config, + required StorageProvider storageProvider, + }) : _config = config, + _storageProvider = storageProvider; + + final ICloudAlbumSourceConfig _config; + final StorageProvider _storageProvider; + 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 (token: ${_config.token})'); + + final localDir = await _storageProvider.getPhotoDirectory(); + await localDir.create(recursive: true); + + // Step 1: get photo metadata + final shard host + final (photos, host) = await _fetchPhotoList(); + _log.info('Found ${photos.length} photos in iCloud album'); + + if (photos.isEmpty) return; + + // Step 2: build guid list and checksum→guid mapping for best derivative. + // webasseturls response is keyed by derivative checksum, NOT photoGuid. + final guids = []; + final checksumToGuid = {}; + final guidToChecksum = {}; + + for (final photo in photos) { + final guid = photo['photoGuid'] as String?; + if (guid == null || guid.isEmpty) continue; + guids.add(guid); + 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 as Map).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) { + checksumToGuid[bestChecksum] = guid; + guidToChecksum[guid] = bestChecksum; + _log.info('Photo ${guid.substring(0, 8)}: best derivative key=$bestKey (${bestW}x${bestH})'); + } + } + + // Step 3: get download URLs — response keys are derivative checksums + final rawUrls = await _fetchAssetUrls(guids: guids, host: host); + _log.info('Got ${rawUrls.length} raw asset URLs for ${guids.length} photos'); + + // Map checksum keys back to photoGuids for file naming + final guidToUrl = {}; + for (final entry in rawUrls.entries) { + final guid = checksumToGuid[entry.key]; + if (guid != null) guidToUrl.putIfAbsent(guid, () => entry.value); + } + _log.info('Matched ${guidToUrl.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'); + + _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) + if (deleteOrphanedFiles) { + await _deleteOrphans(localDir, guids.toSet()); + } + + _log.info('iCloud album sync complete'); + } + + // --------------------------------------------------------------------------- + // API: webstream — returns photo metadata + the final shard host + // --------------------------------------------------------------------------- + + Future<(List>, String)> _fetchPhotoList() async { + final token = _config.token; + 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 List guids, + required String host, + }) async { + final token = _config.token; + 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; + } + + 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/photo_service.dart b/lib/infrastructure/services/photo_service.dart index 32c9cc7..87db24b 100644 --- a/lib/infrastructure/services/photo_service.dart +++ b/lib/infrastructure/services/photo_service.dart @@ -76,6 +76,7 @@ class PhotoService extends ChangeNotifier { bool get isSyncing => _isSyncing; SyncProgress? get syncProgress => _syncProgress; SyncStatus? get syncStatus => _syncStatus; + int get photoCount => _repository.photos.length; Future initialize() async { if (_isInitialized) return; diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart new file mode 100644 index 0000000..2ce6f0c --- /dev/null +++ b/lib/infrastructure/services/web_server_service.dart @@ -0,0 +1,852 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; + +import '../../domain/interfaces/config_provider.dart'; +import '../../domain/interfaces/storage_provider.dart'; +import 'android_runtime_settings_sync.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; + + static const int _maxLogEntries = 500; + final List> _logBuffer = []; + + 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; + } + + 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 { + _sendError(request, 404, 'Not found'); + } + } + + // --------------------------------------------------------------------------- + // 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, + '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, + '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) { + _config.setSourceConfig( + 'icloud_album', Map.from(u['icloud_album'] as Map)); + } + 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); + 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('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}); + } + + // --------------------------------------------------------------------------- + // 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 + +
+
+ Automatic updatesCheck GitHub for new versions + +
+
+ + +
+

Actions

+
+ + +
+
+ + +
+

App Log

+
+ + + +
+
+
+ + +
+

Upload Photos

+
+ Drop photos here, or click to select files +
+ +
+
+ +
+ + +'''; +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9e33cda..0c80774 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 geteiltes Album URL", + "icloudAlbumUrlHint": "https://www.icloud.com/photos/…", + "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..3ac09a5 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", + "icloudAlbumUrlHint": "https://www.icloud.com/photos/…", + "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..4cceb49 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'** + String get icloudAlbumUrl; + + /// No description provided for @icloudAlbumUrlHint. + /// + /// In en, this message translates to: + /// **'https://www.icloud.com/photos/…'** + 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..feccb17 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -100,6 +100,28 @@ 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 geteiltes Album URL'; + + @override + String get icloudAlbumUrlHint => 'https://www.icloud.com/photos/…'; + + @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..7700863 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -99,6 +99,27 @@ 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'; + + @override + String get icloudAlbumUrlHint => 'https://www.icloud.com/photos/…'; + + @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..1d0f984 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'; @@ -123,6 +126,14 @@ 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, + ); + } } return NoOpSyncService(); @@ -138,6 +149,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..ed0cea0 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,6 +54,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse late TextEditingController _webdavUserController; late TextEditingController _webdavPasswordController; late bool _webdavAllowInvalidCertificate; + late TextEditingController _icloudAlbumUrlController; late int _syncIntervalMinutes; late bool _deleteOrphanedFiles; late bool _autostartOnBoot; @@ -110,6 +113,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Track original values to detect changes late String _originalSyncType; late WebDavSourceConfig _originalWebDavSourceConfig; + late String _originalICloudAlbumUrl; @override void initState() { @@ -126,7 +130,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 @@ -214,9 +218,16 @@ class _SettingsScreenState extends State with WidgetsBindingObse ) .toList(growable: false); + final icloudConfig = ICloudAlbumSourceConfig.fromMap( + config.getSourceConfig('icloud_album'), + ); + _icloudAlbumUrlController = TextEditingController(text: icloudConfig.albumUrl) + ..addListener(() => setState(() {})); + // Store original values for comparison on save _originalSyncType = _syncType; _originalWebDavSourceConfig = nextcloudConfig; + _originalICloudAlbumUrl = icloudConfig.albumUrl; // Load saved album selection for device_photos mode final devicePhotosConfig = config.getSourceConfig('device_photos'); @@ -286,6 +297,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse _nextcloudUrlController.dispose(); _webdavUserController.dispose(); _webdavPasswordController.dispose(); + _icloudAlbumUrlController.dispose(); super.dispose(); } @@ -302,19 +314,23 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Detect if sync configuration changed final newNextcloudUrl = _nextcloudUrlController.text.trim(); + final newICloudAlbumUrl = _icloudAlbumUrlController.text.trim(); final newWebDavSourceConfig = _buildWebDavSourceConfig( url: newNextcloudUrl, ); final nextcloudConfigChanged = !_nextcloudConfigsEqual(newWebDavSourceConfig, _originalWebDavSourceConfig); + final icloudConfigChanged = newICloudAlbumUrl != _originalICloudAlbumUrl; final syncConfigChanged = _syncType != _originalSyncType || - (_syncType == 'nextcloud_link' && nextcloudConfigChanged); - final newSyncSourceConfigured = syncConfigChanged && - _syncType == 'nextcloud_link' && - newNextcloudUrl.isNotEmpty; + (_syncType == 'nextcloud_link' && nextcloudConfigChanged) || + (_syncType == 'icloud_album' && icloudConfigChanged); + final newSyncSourceConfigured = syncConfigChanged && ( + (_syncType == 'nextcloud_link' && newNextcloudUrl.isNotEmpty) || + (_syncType == 'icloud_album' && ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).isValid) + ); - 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) @@ -366,7 +382,13 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (_syncType == 'nextcloud_link') { config.setSourceConfig('nextcloud_link', newWebDavSourceConfig.toMap()); } - + if (_syncType == 'icloud_album') { + config.setSourceConfig( + 'icloud_album', + ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).toMap(), + ); + } + await config.save(); // If a new sync source was configured, trigger an immediate sync @@ -380,7 +402,14 @@ class _SettingsScreenState extends State with WidgetsBindingObse @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( @@ -405,13 +434,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,18 +570,47 @@ 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 @@ -671,9 +736,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 +907,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 +928,41 @@ 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, + decoration: InputDecoration( + hintText: l10n.icloudAlbumUrlHint, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 4), + Builder(builder: (ctx) { + final url = _icloudAlbumUrlController.text.trim(); + final valid = url.isEmpty || + ICloudAlbumSourceConfig(albumUrl: url).isValid; + return valid + ? const SizedBox.shrink() + : Text(l10n.icloudAlbumUrlInvalid, + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 12)); + }), + ], + ), + ); + } /// Android only: Show app folder path with warning Widget _buildAppFolderInfo() { @@ -2319,6 +2429,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: From 74a3955aaea6d5939c5e78f694c1f35b636810df Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Thu, 13 Aug 2026 18:11:35 +1000 Subject: [PATCH 2/7] Add sync timeout config, Wi-Fi settings shortcut, and device IP banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync timeout is now configurable (15–120s, default 60s) in both the in-app settings and web UI; passed through to iCloud sync service so slow connections don't fail with the old 30s hard-coded limit - Auto-sync now fires on every save when a source is configured, not only when the URL changes — so tapping Save always kicks off a sync - Wi-Fi Settings shortcut added to Android section of settings screen (opens system Wi-Fi page via MethodChannel) - Device IP/web UI URL banner shown at top of settings screen - Web UI save now calls syncFromConfig so autostart SharedPreferences are updated immediately without requiring an app restart - BootReceiver logs upgraded to Log.i so they appear in release builds - Add FRAME_SETUP.md with quick ADB commands for setting up a new frame --- FRAME_SETUP.md | 42 ++++++++ .../openphotoframe/ScreenControlHandler.kt | 7 ++ lib/domain/interfaces/config_provider.dart | 5 +- .../services/icloud_album_sync_service.dart | 6 +- .../services/json_config_service.dart | 12 ++- .../native_screen_control_service.dart | 7 +- .../services/web_server_service.dart | 12 +++ lib/main.dart | 1 + lib/ui/screens/settings_screen.dart | 95 +++++++++++++++++-- 9 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 FRAME_SETUP.md diff --git a/FRAME_SETUP.md b/FRAME_SETUP.md new file mode 100644 index 0000000..592fb4b --- /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 (e.g. `c3d9b8674f4b94f6`). 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/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt b/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt index 15af24e..9eca93c 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 @@ -58,6 +58,13 @@ 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) + } "turnScreenOff" -> { val success = turnScreenOff() result.success(success) diff --git a/lib/domain/interfaces/config_provider.dart b/lib/domain/interfaces/config_provider.dart index 5bec632..fd885bb 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); diff --git a/lib/infrastructure/services/icloud_album_sync_service.dart b/lib/infrastructure/services/icloud_album_sync_service.dart index 8909d90..2e919e3 100644 --- a/lib/infrastructure/services/icloud_album_sync_service.dart +++ b/lib/infrastructure/services/icloud_album_sync_service.dart @@ -19,17 +19,19 @@ class ICloudAlbumSyncException implements Exception { } class ICloudAlbumSyncService implements SyncProvider { - static const Duration _requestTimeout = Duration(seconds: 30); static const Duration _downloadIdleTimeout = Duration(minutes: 15); ICloudAlbumSyncService({ required ICloudAlbumSourceConfig config, required StorageProvider storageProvider, + int timeoutSeconds = 60, }) : _config = config, - _storageProvider = storageProvider; + _storageProvider = storageProvider, + _requestTimeout = Duration(seconds: timeoutSeconds); final ICloudAlbumSourceConfig _config; final StorageProvider _storageProvider; + final Duration _requestTimeout; final _log = Logger('ICloudAlbumSyncService'); @override diff --git a/lib/infrastructure/services/json_config_service.dart b/lib/infrastructure/services/json_config_service.dart index 7b87361..a7b45dd 100644 --- a/lib/infrastructure/services/json_config_service.dart +++ b/lib/infrastructure/services/json_config_service.dart @@ -272,12 +272,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; diff --git a/lib/infrastructure/services/native_screen_control_service.dart b/lib/infrastructure/services/native_screen_control_service.dart index 5c899e4..555211a 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,9 @@ class NativeScreenControlService { return true; } } + + static Future openWifiSettings() async { + if (!isSupported) return; + await _channel.invokeMethod('openWifiSettings'); + } } diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart index 2ce6f0c..10c22af 100644 --- a/lib/infrastructure/services/web_server_service.dart +++ b/lib/infrastructure/services/web_server_service.dart @@ -126,6 +126,7 @@ class WebServerService { '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, @@ -192,6 +193,7 @@ class WebServerService { 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); @@ -544,6 +546,11 @@ button{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-size: +
+ + + +
Delete photos removed from sourceRemove local files no longer on server @@ -664,6 +671,8 @@ function applyConfig(c){ // Sync const si=c.sync_interval_minutes??15; setRange('sync-int',si); syncLbl(si); + const st=c.sync_timeout_seconds??60; + setRange('sync-timeout',st); syncTimeoutLbl(st); setCheck('del-orphans', c.delete_orphaned_files??false); // Android @@ -707,9 +716,11 @@ document.getElementById('fri-sat-on').addEventListener('change',e=>{ function durLbl(v){const s=+v;document.getElementById('dur-lbl').textContent=s<60?s+'s':(s/60|0)+'m'+(s%60?(s%60)+'s':'');} function transLbl(v){document.getElementById('trans-lbl').textContent=(+v/1000).toFixed(1)+'s';} function syncLbl(v){const m=+v;document.getElementById('sync-lbl').textContent=m?m+'m':'off';} +function syncTimeoutLbl(v){document.getElementById('sync-timeout-lbl').textContent=v+'s';} document.getElementById('slide-dur').addEventListener('input',e=>durLbl(e.target.value)); document.getElementById('trans-dur').addEventListener('input',e=>transLbl(e.target.value)); document.getElementById('sync-int').addEventListener('input',e=>syncLbl(e.target.value)); +document.getElementById('sync-timeout').addEventListener('input',e=>syncTimeoutLbl(e.target.value)); /* ---- save ---- */ async function saveSettings(){ @@ -729,6 +740,7 @@ async function saveSettings(){ transition_duration_ms:+document.getElementById('trans-dur').value, blur_borders:document.getElementById('blur-borders').checked, sync_interval_minutes:+document.getElementById('sync-int').value, + sync_timeout_seconds:+document.getElementById('sync-timeout').value, delete_orphaned_files:document.getElementById('del-orphans').checked, show_clock:document.getElementById('show-clock').checked, clock_size:document.getElementById('clock-size').value, diff --git a/lib/main.dart b/lib/main.dart index 1d0f984..4f49c4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -132,6 +132,7 @@ class OpenPhotoFrameApp extends StatelessWidget { return ICloudAlbumSyncService( config: icloudConfig, storageProvider: storage, + timeoutSeconds: config.syncTimeoutSeconds, ); } } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index ed0cea0..290b752 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -56,6 +56,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse late bool _webdavAllowInvalidCertificate; late TextEditingController _icloudAlbumUrlController; late int _syncIntervalMinutes; + late int _syncTimeoutSeconds; late bool _deleteOrphanedFiles; late bool _autostartOnBoot; late bool _keepAliveEnabled; @@ -138,6 +139,7 @@ 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; _autostartOnBoot = config.autostartOnBoot; _keepAliveEnabled = config.keepAliveEnabled; @@ -344,6 +346,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse config.customPhotoPath = null; } config.syncIntervalMinutes = _syncIntervalMinutes; + config.syncTimeoutSeconds = _syncTimeoutSeconds; config.deleteOrphanedFiles = _deleteOrphanedFiles; config.autostartOnBoot = _autostartOnBoot; config.keepAliveEnabled = _keepAliveEnabled; @@ -390,13 +393,16 @@ class _SettingsScreenState extends State with WidgetsBindingObse } 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' && + ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).isValid); + if (sourceIsConfigured) { final photoService = context.read(); - // Don't await - let it run in the background - photoService.triggerSync(); + photoService.triggerSync(); // fire-and-forget } } @@ -423,9 +429,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), @@ -615,9 +645,14 @@ class _SettingsScreenState extends State with WidgetsBindingObse // 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), @@ -664,7 +699,17 @@ 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(), + ), + + const SizedBox(height: 8), + SwitchListTile( title: Text(AppLocalizations.of(context)!.startOnBoot), subtitle: Text(AppLocalizations.of(context)!.startOnBootSubtitle), @@ -1715,6 +1760,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), From 543aa251165bde588bdd8bb21b90fdffbfae61a9 Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Sat, 19 Sep 2026 16:23:10 +1000 Subject: [PATCH 3/7] Fix process-scan pile-up and document the Pexar PX-110 frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a metrics-endpoint bug that could starve a low-end frame of CPU: - WebServerService._refreshProcessStatus() walked /proc/[0-9]*/cmdline in a shell loop, forking two processes per PID (~520 per scan on the PX-110). Replaced with a single `su 0 ps -A -o NAME` - The throttle timestamp was assigned after the await with no in-flight guard, so one slow scan let every later scrape start another 520-fork scan on top of the stuck one. Timestamp is now set up front, a 10s timeout bounds the call, and an in-flight flag allows only one scan - On 2026-09-19 this pile-up, together with an ActivityManager respawn loop, wedged system_server for eight hours: an orphaned `tr` was found spinning at 100% of a core Also adds FRAMEO.md coverage for the Pexar Frame PX-110 (MediaTek MT8167) that replaced the retired Frameo 106K: - Vendor install lock: the ROM blocks sideloading while ro.vendor.custom_recover is 1. The value is byte 0x3FE of the proinfo partition, published by nvram_daemon. Clearing it unblocks installs without touching the verity-protected system partition - Stock service survey. Never pm disable-user a package marked android:persistent="true" on this ROM — that is what caused the respawn loop. Disable the components instead and leave the package enabled - 32-bit only (armeabi-v7a); an arm64-only APK will not install - Build note: Flutter picks Android Studio's OpenJDK 25 JBR, which Gradle 8.14 cannot parse. Point --jdk-dir at Temurin 21 - Pinning the app as default HOME, and the outage post-mortem Ignores /backups/, which holds raw partition dumps containing the device serial and MAC address. --- .gitignore | 4 + FRAMEO.md | 537 ++++++++++++++++++ FRAME_SETUP.md | 2 +- .../openphotoframe/ScreenControlHandler.kt | 180 ++++++ lib/domain/interfaces/config_provider.dart | 6 + lib/domain/models/photo_entry.dart | 1 + .../file_system_photo_repository.dart | 2 +- .../repositories/hybrid_photo_repository.dart | 6 +- .../android_runtime_settings_sync.dart | 18 + .../services/emmc_cache_warm_service.dart | 12 + .../services/icloud_album_source_config.dart | 126 +++- .../services/icloud_album_sync_service.dart | 224 ++++++-- .../services/json_config_service.dart | 16 + .../native_screen_control_service.dart | 35 ++ .../services/photo_service.dart | 17 +- .../services/web_server_service.dart | 281 ++++++++- .../services/wifi_adb_service.dart | 12 + lib/l10n/app_de.arb | 4 +- lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations.dart | 4 +- lib/l10n/app_localizations_de.dart | 5 +- lib/l10n/app_localizations_en.dart | 5 +- lib/main.dart | 6 + lib/ui/screens/settings_screen.dart | 149 +++-- scripts/fix-frameo.sh | 56 ++ 25 files changed, 1556 insertions(+), 156 deletions(-) create mode 100644 FRAMEO.md create mode 100644 lib/infrastructure/services/emmc_cache_warm_service.dart create mode 100644 lib/infrastructure/services/wifi_adb_service.dart create mode 100755 scripts/fix-frameo.sh diff --git a/.gitignore b/.gitignore index 72abdd3..e6adb2b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ 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/ diff --git a/FRAMEO.md b/FRAMEO.md new file mode 100644 index 0000000..27b53d9 --- /dev/null +++ b/FRAMEO.md @@ -0,0 +1,537 @@ +# Frameo device notes + +Covers two frames: the Frameo 106K (retired, returned to Amazon) below, and the +Pexar Frame PX-110 that replaced it — see [Pexar Frame PX-110](#pexar-frame-px-110-mediatek-mt8167). + +Device: Frameo 106K (10" photo frame) +SoC: Rockchip RK3326 +Android: 11 (user build, `ro.debuggable=1`, SELinux permissive) +IP: 192.168.0.0 +USB serial: `SERIAL_REDACTED` + +## Rockchip RK3326 memtrack HAL crash loop + +### What happens + +The `android.hardware.memtrack@1.0-service` HAL crashes on every boot within the first 20 seconds. The crash is a `FORTIFY: readdir: null DIR*` abort inside `find_dir()` in `memtrack.rk3326.so` — a firmware bug in the Rockchip RK3326 vendor library. + +Because the service has no `oneshot` flag in its RC file, Android init restarts it after each crash. Left unchecked, the crash loop consumes CPU, floods the crash log, and eventually destabilises the system (system_server dies, device becomes unresponsive). + +The memtrack HAL only feeds diagnostic tools (memory profiling). Stopping it has no effect on normal operation. + +### Automatic fix (app-based) + +OpenPhotoFrame stops the service automatically on every boot. On `BOOT_COMPLETED`, `ScreenControlHandler.stopCrashingMemtrackService()` runs: + +```kotlin +Runtime.getRuntime().exec( + arrayOf("/system/xbin/su", "0", "/system/bin/stop", "vendor.memtrack-hal-1-0") +) +``` + +This works because: +- SELinux is permissive on this device — domain transitions are logged but not blocked +- The app process has `NoNewPrivs=0` and `Seccomp=0` — the setuid `su` binary gains root +- `stop` marks the service disabled in init; it will not restart until the next reboot + +The fix only runs on Rockchip devices (detected via `Build.HARDWARE` containing `"rk"` or the presence of `/vendor/lib/hw/memtrack.rk3326.so`). + +### Manual fallback + +If the app hasn't launched yet or isn't installed, run the script with the USB cable plugged in: + +``` +./scripts/fix-frameo.sh +``` + +This stops the service via ADB root shell and re-enables WiFi ADB. + +### Why a permanent firmware fix isn't possible + +The RC file that needs `oneshot` added lives at `/vendor/etc/init/android.hardware.memtrack@1.0-service.rc`. The vendor partition is dm-verity protected: + +- Direct writes to `/vendor` are blocked +- `adb disable-verity` fails (requires a userdebug build) +- `adb remount` sets up overlayfs, but the upper layer is a tmpfs at `/mnt/scratch` — not backed by persistent storage, so changes are lost on reboot +- There is no scratch partition in the partition table and no `/data/gsi/scratch.img` + +Magisk would solve this (it injects scripts from `/data/adb/` before HAL services start) but requires flashing a custom boot image. + +## OTA update reboots (adups FOTA) + +### What happens + +The device ships with `com.adups.fota` (Rockchip's firmware update service) and `android.rockchip.update.service`. Both register `RTC_WAKEUP` alarms and can trigger a clean `reboot` when they decide to check for or install firmware. The reboot leaves no crash trace — `sys.boot.reason` is simply `reboot` — making it look like a mystery crash. + +Five services have been disabled for user 0: + +```sh +adb shell pm disable-user --user 0 com.adups.fota +adb shell pm disable-user --user 0 android.rockchip.update.service +adb shell pm disable-user --user 0 net.frameo.frame +adb shell pm disable-user --user 0 com.cghs.stresstest +adb shell pm disable-user --user 0 com.DeviceTest +``` + +**Important — use `pm disable-user`, not `pm uninstall`.** + +`pm uninstall -k --user 0` only removes the `/data/app/` update overlay. If the app ships in `/system/priv-app/` or `/system/app/`, Android silently reverts to that ROM copy, which then runs normally. `net.frameo.frame` lives at `/system/priv-app/Frameo.apk` — when the overlay was uninstalled, the ROM copy (v1.30.13) became active again, connected to Frameo's servers, downloaded an update, and triggered a clean `reboot` every 2–4 hours. + +`pm disable-user --user 0` marks the package as `COMPONENT_ENABLED_STATE_DISABLED_USER` (enabled=3), which blocks both the ROM copy and any future updates from running. + +`com.adups.fota` and `com.cghs.stresstest` are declared `android:persistent="true"`. Android's `ActivityManagerService` ignores `pm disable` for persistent apps — `pm disable` alone is not enough. `pm disable-user --user 0` still works because it marks the package as disabled for the user before ActivityManager evaluates persistence. + +`com.cghs.stresstest` is a Rockchip factory stress-test tool that runs on a schedule and is designed to reboot the device as part of its test cycle. This was causing the ~3 hour reboot cycle observed after OTA services were removed. + +If the device starts rebooting unexpectedly again, check `sys.boot.reason`. If it says `reboot` rather than `kernel_panic` or `watchdog`, an OTA service is likely responsible. Re-run the `pm uninstall` commands above. + +## WiFi ADB + +WiFi ADB (TCP mode) does not persist across reboots. To re-enable it after a reboot, plug in USB and run: + +``` +./scripts/fix-frameo.sh +``` + +Or manually: + +```sh +adb -s SERIAL_REDACTED tcpip 5555 +adb connect 192.168.0.0:5555 +``` + +## eMMC failure and device retirement + +### What happened + +The Frameo 106K developed progressive eMMC (internal flash storage) failure over its life as a photo frame running OpenPhotoFrame. + +**First crash (system partition):** Bad sectors appeared in the system partition (`mmcblk2p4`/`mmcblk2p6`), causing a kernel I/O error that brought down the device. The affected sectors mapped to system binaries (`statsd`, `audioserver`, `netd`, `wificond`, etc.). + +**Mitigation — eMMC cache warming:** OpenPhotoFrame added a boot-time feature (toggleable in settings, off by default) that reads the affected system files into the page cache on startup. This prevents the kernel from needing to re-read those sectors from eMMC while daemons are running. Implemented in `ScreenControlHandler.warmEmmcFileCache()`. + +**Second crash (userdata partition, ~1 hour later):** 13 new bad sectors appeared in `mmcblk2p15` (userdata, f2fs on `dm-6`). These mapped to: +- 4 JPEG photo files in OpenPhotoFrame's cache directory +- `IpMemoryStore.db` (network memory — not critical) +- f2fs node inode blocks (filesystem metadata — serious) + +The node inode damage indicated the f2fs inode table itself was at risk, meaning the filesystem could become unmountable on the next crash. + +### Diagnosis tools used + +```sh +# Identify which partition a bad sector lives in +adb shell su 0 cat /proc/partitions +adb shell su 0 cat /sys/kernel/debug/mmc0/mmc0:0001/ext_csd # eMMC health + +# Map a bad sector to a file on f2fs +# (sector number from dmesg / Prometheus mmc_error_count metric) +# userdata starts at sector 6863612 on mmcblk2 +# f2fs block = (bad_sector - partition_start) / 8 +adb shell su 0 dump.f2fs -b /dev/block/dm-6 + +# Find filename from inode number +adb shell su 0 find /data -inum +``` + +### Outcome + +The eMMC failure region was spreading to new partitions. The device was beyond reliable software mitigation. Decision: factory reset and return to Amazon under hardware failure warranty. + +eMMC failure is a documented failure mode for Frameo-brand devices. Community reports: +- https://forums.justuseapp.com/en/post/QERMOAEXL7/frameo-keeps-rebooting-over-and-over +- https://www.justanswer.com/electronics/tksy5-frameo-digital-photo-frame-stuck-rebooting.html +- https://www.devicepitfalls.com/frameo-stuck-on-startup-screen/ + +### Factory reset procedure + +The recovery UI had only one physical button which activated the first menu item ("Reboot system now"), making menu navigation impossible. Factory reset was triggered via ADB: + +```sh +# Write wipe command to recovery command file +adb -s SERIAL_REDACTED shell su 0 sh -c \ + 'mkdir -p /cache/recovery && printf "--wipe_data\n" > /cache/recovery/command && sync' + +# Reboot into recovery — it reads the command file and wipes automatically +adb -s SERIAL_REDACTED reboot recovery +``` + +After reset: `net.frameo.frame` (stock Frameo) present, `io.github.micw.openphotoframe` gone, `/data/media/0/frameo_files/media/` empty. Device verified clean and returned to Amazon. + +## ADB quick reference + +```sh +# USB +adb -s SERIAL_REDACTED shell + +# WiFi (after enabling TCP mode) +adb -s 192.168.0.0:5555 shell + +# Deploy APK +adb -s 192.168.0.0:5555 install -r build/app/outputs/flutter-apk/app-release.apk + +# Check memtrack service status +adb -s 192.168.0.0:5555 shell getprop init.svc.vendor.memtrack-hal-1-0 + +# Stop memtrack manually (as root) +adb -s SERIAL_REDACTED shell stop vendor.memtrack-hal-1-0 +``` + +# Pexar Frame PX-110 (MediaTek MT8167) + +Replacement for the retired Frameo 106K. Different vendor and SoC, so none of the +Rockchip notes above apply — there is no memtrack HAL and no adups FOTA package. + +Device: Pexar Frame, `ro.product.brand=Lexar`, `ro.product.device=dpf1106_mk_32` +SoC: MediaTek MT8167 (`ro.board.platform=mt8167`), 32-bit only (`armeabi-v7a`) +Android: 11 (user build, `ro.debuggable=0`, SELinux permissive) +RAM: 2 GB +USB serial: `FRAME_SERIAL_REDACTED` +IP: 192.168.0.0 + +Root works despite `ro.debuggable=0`: `/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 +``` + +## Build constraint: 32-bit only + +`ro.product.cpu.abilist` is `armeabi-v7a,armeabi` — there is no arm64 slice. The +default `flutter build apk --release` fat APK works because it bundles +`armeabi-v7a` alongside `arm64-v8a` and `x86_64`. Building arm64-only +(`--target-platform android-arm64`) or picking the wrong split from +`--split-per-abi` produces an APK this frame cannot run. Check before deploying: + +```sh +unzip -l build/app/outputs/flutter-apk/app-release.apk | grep -o 'lib/[^/]*' | sort -u +# must include lib/armeabi-v7a +``` + +The two Rockchip-specific workarounds in the app are inert here, as intended: +`stopCrashingMemtrackService()` gates on `Build.HARDWARE` containing `"rk"` +(this device reports `mt8167`) and on `/vendor/lib/hw/memtrack.rk3326.so`, which +does not exist. The eMMC cache-warming feature is off by default and targets the +106K's failing sectors, so leave it off. + +## Vendor install lock (`ro.vendor.custom_recover`) + +### What happens + +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`) to gate installs on a property: + +```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 from `CommandQueue.isRecoveryFirstBoot()`, and +`panelsEnabled()` returns false while it is `1`. So the flag is a kiosk lockdown +switch: it blocks sideloading *and* disables the notification shade. + +### Where the value comes from + +`/vendor/bin/nvram_daemon` publishes it. The relevant code (Thumb-2, at `0x2eb4`) +reads the 1024-byte product-info record, takes the byte at record offset `0x3FE`, +formats it, and calls `property_set("ro.vendor.custom_recover", )`. + +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 you have 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 "FRAME_SERIAL_REDACTED" +# offset 0x6e Wi-Fi/BT MAC 84:5d:d7:0b:ce:85 +# 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 +P=/dev/block/platform/soc/11120000.mmc/by-name/proinfo +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: + +```sh +adb shell "/system/xbin/su 0 dd if=$P of=/data/local/tmp/proinfo.bak bs=4096" +adb pull /data/local/tmp/proinfo.bak backups/proinfo-FRAME_SERIAL_REDACTED.bak +``` + +A pre-change backup sits at `backups/proinfo-FRAME_SERIAL_REDACTED.bak`, untracked +(sha256 `2dbf2e37f726ce7e2a8417469c5ad00517f86113b248a1d22809d8ca62ed651e`). Keep it +somewhere safe — restoring it needs a frame that still boots. + +Side effect, as expected from `panelsEnabled()`: the notification shade and quick +settings now pull down. Restoring the byte reverts both effects. + +### Why not patch the system partition instead + +`/` 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 an I/O +error (harmless — the flag resets at reboot). `adb disable-verity` and `adb remount` +both need a userdebug build. Installing the app into `/system/app` would therefore +risk an unbootable frame; the `proinfo` byte avoids verity entirely. + +## Flaky USB + +USB ADB re-enumerates constantly on this frame — the transport id climbed past 100 +in one session, and commands die mid-run with `device not found`. It is not +rebooting; check `/proc/uptime` to confirm it keeps climbing. Switch to Wi-Fi ADB +and leave the cable out. + +## Wi-Fi ADB + +As on the old frame, TCP mode does not survive a reboot. Plug in USB and run: + +```sh +adb -s FRAME_SERIAL_REDACTED tcpip 5555 +adb connect 192.168.0.0:5555 +``` + +## ADB quick reference + +```sh +# USB +adb -s FRAME_SERIAL_REDACTED shell + +# Wi-Fi +adb -s 192.168.0.0:5555 shell + +# Deploy (needs the install lock cleared first) +adb -s 192.168.0.0:5555 install -r -g build/app/outputs/flutter-apk/app-release.apk + +# Launch +adb -s 192.168.0.0:5555 shell monkey -p io.github.micw.openphotoframe \ + -c android.intent.category.LAUNCHER 1 +``` + +## Stock service survey + +Surveyed 2026-09-15. Three of the five packages disabled on the Frameo 106K are +present here; `com.cghs.stresstest` and `android.rockchip.update.service` are not. +Never `pm uninstall` these — see the reasoning under +[OTA update reboots](#ota-update-reboots-adups-fota). But **do not +`pm disable-user` a package marked `android:persistent="true"` on this ROM +either**: that caused a four-day outage, described in +[The 2026-09-19 respawn-loop outage](#the-2026-09-19-respawn-loop-outage). The +106K note claiming `disable-user` copes with persistent apps does not hold here. + +`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 instead, so ActivityManager's persistent process starts once and then +idles with nothing left to trigger it: + +```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` +(default/enabled) — **not** `enabled=3`. Its `Application.onCreate` still +re-registers a daily `RTC_WAKEUP`, but that alarm's `PendingIntent` targets the +now-disabled `MyReceiver`, so it fires into nothing. + +### com.adups.fota v5.30 — active reboot risk + +At `/product/app/FotaApp/`. Same OTA updater that rebooted the 106K every 2–4 hours. +Flags `SYSTEM PERSISTENT`; holds `REBOOT`, `RECOVERY`, `SCHEDULE_EXACT_ALARM`, +`RECEIVE_BOOT_COMPLETED`, `WAKE_LOCK`. It had a daily `RTC_WAKEUP` queued +(`*walarm*:com.adups.fota.custom_service`). + +Unlike the Rockchip build, this one also registers Firebase Cloud Messaging +(`.service.FcmService`, `FirebaseMessagingService`) alongside `.GoogleOtaClient`, so +an OTA can be pushed remotely at any time rather than only on the daily poll. Nothing +had downloaded yet — `/data/data/com.adups.fota/files` and `/data/ota_package` were +still at the factory date, and `/cache/recovery/command` was absent. + +### com.DeviceTest — latent boot-loop + +At `/system/app/HCNDeviceTest/`. Shares the **system UID** (`userId=1000`) and holds +`REBOOT`, `MASTER_CLEAR` and `DEVICE_POWER`. Starts on `BOOT_COMPLETED` via +`com.DeviceTest.BootReceiver`, which is the factory burn-in trigger: + +```java +SharedPreferences sp = context.getSharedPreferences("AgingTest", 0); +if (!sp.getBoolean("istestend", true)) { // default TRUE + Intent i = new Intent(context, com.DeviceTest.AgingTest.class); + i.setFlags(FLAG_ACTIVITY_NEW_TASK); + i.putExtra("Reboot", "reboot"); + context.startActivity(i); +} +``` + +The default is `true`, so an absent flag leaves the soak test dormant — and +`/data/data/com.DeviceTest/shared_prefs/` was empty, so it had never armed. The risk +is latent: anything that runs the factory test and writes `istestend=false` would +launch `AgingTest` with a reboot extra on every subsequent boot. It also ships +`RecoveryTestService` and `TestService`. + +This is the structural equivalent of `com.cghs.stresstest` on the 106K, which is +worth knowing if a future frame reboots on a fixed cycle: check the aging-test prefs +before assuming OTA. + +### net.frameo.frame v1.26.26 — display conflict, not reboots + +At `/system/system_ext/priv-app/Frameo/`, `PRIVILEGED`. Unlike the 106K copy it does +**not** hold `REBOOT`, so it is not a reboot vector here. It does hold `SET_TIME` and +had an `RTC_WAKEUP` for `.utils.StandbyBroadcastReceiver`, which manages the screen +and would fight OpenPhotoFrame for the display. It is also a self-update vector. + +Disabling it is safe on this device because two other HOME activities remain: + +``` +com.android.launcher3/.uioverrides.QuickstepLauncher +io.github.micw.openphotoframe/.MainActivity <- app declares itself HOME +com.android.settings/.FallbackHome +``` + +### Left enabled + +- `com.debug.loggerui` + the `aee_aed` / `aee_aedv` / `mobile_log_d` daemons. Only + boot-receiver capability, no reboot vector. Combined on-disk footprint was ~530 KB + (`/data/debuglogger` 11K, `/data/aee_exp` 365K, `/data/anr` 3.5K, + `/data/tombstones` 156K) with `/data` at 5% of 26 GB — no eMMC wear pressure, in + contrast to the 106K. Worth re-checking with `du -sh` if storage creeps up. +- `com.mediatek.engineermode` — holds `MASTER_CLEAR` but only launches from the UI. +- `com.mediatek.miravision.ui`, `com.mediatek.callrecorder`, + `com.mediatek.lbs.em2.ui`, `com.mediatek.capctrl.service` — no reboot vectors. + +### Checking for a regression + +```sh +adb shell dumpsys alarm | grep -c "RTC_WAKEUP #" # expect 0 +adb shell dumpsys package com.adups.fota | grep -m1 enabled= # expect enabled=3 +adb shell getprop sys.boot.reason +``` + +A `sys.boot.reason` of plain `reboot` (rather than `reboot,shell` from your own +`adb reboot`, or `kernel_panic` / `watchdog`) points at one of the above waking up. + +## The 2026-09-19 respawn-loop outage + +The frame wedged showing "process system isn't responding" with dead touch input. +Prometheus stopped scraping at 07:31; the device never rebooted (uptime ran +continuously through the whole event). + +### Cause 1 — `pm disable-user` on a persistent package + +`com.adups.fota` is `android:persistent="true"`. Disabling it with +`pm disable-user` on 2026-09-15 meant ActivityManager kept respawning the +persistent process, it died immediately because the package was disabled, and AMS +respawned it again — **17 times 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 the loop immediately. Use the component-level +disable in [Stock service survey](#stock-service-survey) instead. + +### Cause 2 — unbounded process-scan pile-up in the app + +`WebServerService._refreshProcessStatus()` shelled out per metrics scrape with + +``` +su 0 sh -c "for f in /proc/[0-9]*/cmdline; do tr '\0' '\n' < $f | head -1; done" +``` + +which forks two processes per PID — about 520 per scan on this device. Worse, the +throttle timestamp was assigned *after* the `await` with no in-flight guard, so a +single slow scan meant every later scrape launched another 520-fork scan on top of +the stuck one. An orphaned `tr` was found spinning at 100% of a core. + +Fixed by switching to one `su 0 ps -A -o NAME`, adding a 10s timeout, setting the +throttle before the await, and adding an in-flight guard. + +### Why it presented as a system hang + +Sustained ~90% system CPU starved everything. `system_server` ANR'd, and its ANR +handler then jammed: thread `AnrConsumer` blocked in `debuggerd_trigger_dump` → +`recvfrom` while holding MediaTek's `AnrManagerService$AnrDumpRecord` lock, so +every subsequent ANR queued behind it. Both `system_server` and the app showed +*idle* main threads (`epoll_wait` / `nativePollOnce`) — the hang was 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 throughout even while the UI was + frozen — always try `adb connect` and `curl :8080/metrics` before power-cycling. +- ANR traces live in `/data/anr/` (`anr_*` for the report, `trace_*` for the dumped + process). They rotate quickly; 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). Nothing resembling the 106K's hardware failure. + +## Building + +Flutter 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" +``` + +This is a machine-local Flutter setting, not repo state. Undo with +`flutter config --jdk-dir=`. + +## Making the app the default launcher + +With `net.frameo.frame` disabled there are still two HOME candidates +(`com.android.launcher3` and the app), and no default was set — so the frame risked +booting 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 +``` + +The setting survives reboot. Combined with the app's `autostart_on_boot` and +`wifi_adb_enabled` config flags, a cold boot reaches photos in about 30 seconds and +brings Wi-Fi ADB back on its own — the app re-runs `setprop service.adb.tcp.port +5555` and restarts `adbd` (`ScreenControlHandler.kt`). That self-healing matters +because USB on this frame is unreliable. diff --git a/FRAME_SETUP.md b/FRAME_SETUP.md index 592fb4b..94bccb6 100644 --- a/FRAME_SETUP.md +++ b/FRAME_SETUP.md @@ -6,7 +6,7 @@ adb devices -l ``` -Note the device serial (e.g. `c3d9b8674f4b94f6`). Use `-s ` in all commands below if multiple devices are connected. +Note the device serial (e.g. `SERIAL_REDACTED`). Use `-s ` in all commands below if multiple devices are connected. ## 2. Install the APK 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 9eca93c..39e276c 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,14 @@ class ScreenControlHandler(private val context: Context) { } fun configureChannel(flutterEngine: FlutterEngine) { + // The Rockchip RK3326 memtrack HAL has a bug (readdir on null DIR*) that causes + // it to crash in a loop and eventually destabilise the system. Stop it once on + // startup — it only feeds diagnostic tools and is not needed for normal operation. + stopCrashingMemtrackService() + val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + if (prefs.getBoolean("flutter.wifi_adb_enabled", true)) enableWifiAdb() + if (prefs.getBoolean("flutter.emmc_cache_warm_enabled", false)) warmEmmcFileCache() + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "isDeviceAdminEnabled" -> { @@ -65,6 +75,28 @@ class ScreenControlHandler(private val context: Context) { 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) @@ -89,6 +121,12 @@ class ScreenControlHandler(private val context: Context) { "isScreenOn" -> { result.success(powerManager.isInteractive) } + "getMemoryInfo" -> { + result.success(getMemoryInfo()) + } + "getThermalInfo" -> { + result.success(getThermalInfo()) + } else -> { result.notImplemented() } @@ -219,6 +257,148 @@ 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 warmEmmcFileCache() { + // These files sit on eMMC sectors that have shown intermittent I/O errors. + // Reading them at startup populates the page cache so the kernel does not need + // to fetch them from the eMMC when a running daemon first touches a cold page. + val files = listOf( + "/system/apex/com.android.os.statsd/bin/statsd", + "/system/bin/audioserver", + "/system/bin/netd", + "/system/bin/vdc", + "/system/bin/wificond", + "/system/etc/task_profiles.json", + "/system/lib/libandroid_runtime.so" + ) + for (path in files) { + try { + java.io.File(path).inputStream().use { stream -> + val buf = ByteArray(65536) + while (stream.read(buf) != -1) { /* discard — just pulling pages into cache */ } + } + Log.i(TAG, "eMMC cache warm: $path") + } catch (e: Exception) { + Log.w(TAG, "eMMC cache warm failed for $path: ${e.message}") + } + } + } + + 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}") + } + } + + private fun stopCrashingMemtrackService() { + // Only applies to Rockchip RK3326 devices — the memtrack HAL on this SoC has a + // bug (readdir on null DIR*) that causes it to crash in a loop and destabilise + // the system. It only feeds diagnostic tools so stopping it is safe. + // + // The device has SELinux permissive, no PR_SET_NO_NEW_PRIVS, and a setuid-root + // /system/xbin/su binary — so the app process can escalate to root via su. + val isRockchip = Build.HARDWARE.contains("rk", ignoreCase = true) || + java.io.File("/vendor/lib/hw/memtrack.rk3326.so").exists() + if (!isRockchip) return + try { + val proc = Runtime.getRuntime().exec( + arrayOf("/system/xbin/su", "0", "/system/bin/stop", "vendor.memtrack-hal-1-0") + ) + val exit = proc.waitFor() + if (exit == 0) { + Log.i(TAG, "Stopped vendor.memtrack-hal-1-0 via su (buggy Rockchip RK3326 HAL)") + } else { + Log.w(TAG, "su stop returned exit code $exit, falling back to direct stop") + Runtime.getRuntime().exec("stop vendor.memtrack-hal-1-0") + } + } catch (e: Exception) { + Log.d(TAG, "Could not stop memtrack service: ${e.message}") + } + } + /** * Wake up the screen immediately. */ diff --git a/lib/domain/interfaces/config_provider.dart b/lib/domain/interfaces/config_provider.dart index fd885bb..ba6be49 100644 --- a/lib/domain/interfaces/config_provider.dart +++ b/lib/domain/interfaces/config_provider.dart @@ -40,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); + + bool get emmcCacheWarmEnabled; // Preload flagged system files into page cache on boot (device-specific) + set emmcCacheWarmEnabled(bool 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..6de2139 100644 --- a/lib/infrastructure/services/android_runtime_settings_sync.dart +++ b/lib/infrastructure/services/android_runtime_settings_sync.dart @@ -1,11 +1,17 @@ import '../../domain/interfaces/config_provider.dart'; import 'autostart_service.dart'; +import 'emmc_cache_warm_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); + + Future setEmmcCacheWarmEnabled(bool enabled); } class SharedPreferencesAndroidRuntimeSettingsWriter @@ -19,6 +25,16 @@ class SharedPreferencesAndroidRuntimeSettingsWriter Future setKeepAliveEnabled(bool enabled) { return KeepAliveService.setEnabled(enabled); } + + @override + Future setWifiAdbEnabled(bool enabled) { + return WifiAdbService.setEnabled(enabled); + } + + @override + Future setEmmcCacheWarmEnabled(bool enabled) { + return EmmcCacheWarmService.setEnabled(enabled); + } } class AndroidRuntimeSettingsSync { @@ -30,5 +46,7 @@ class AndroidRuntimeSettingsSync { Future syncFromConfig(ConfigProvider configProvider) async { await _writer.setAutostartEnabled(configProvider.autostartOnBoot); await _writer.setKeepAliveEnabled(configProvider.keepAliveEnabled); + await _writer.setWifiAdbEnabled(configProvider.wifiAdbEnabled); + await _writer.setEmmcCacheWarmEnabled(configProvider.emmcCacheWarmEnabled); } } \ No newline at end of file diff --git a/lib/infrastructure/services/emmc_cache_warm_service.dart b/lib/infrastructure/services/emmc_cache_warm_service.dart new file mode 100644 index 0000000..6b1d942 --- /dev/null +++ b/lib/infrastructure/services/emmc_cache_warm_service.dart @@ -0,0 +1,12 @@ +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; + +class EmmcCacheWarmService { + static const String _key = 'emmc_cache_warm_enabled'; + + static Future setEnabled(bool enabled) async { + if (!Platform.isAndroid) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key, enabled); + } +} diff --git a/lib/infrastructure/services/icloud_album_source_config.dart b/lib/infrastructure/services/icloud_album_source_config.dart index 25570ce..02f250b 100644 --- a/lib/infrastructure/services/icloud_album_source_config.dart +++ b/lib/infrastructure/services/icloud_album_source_config.dart @@ -1,16 +1,23 @@ -class ICloudAlbumSourceConfig { - final String albumUrl; +/// 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); - const ICloudAlbumSourceConfig({this.albumUrl = ''}); + // 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(); - /// Extracts the 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) String get token { - final trimmed = albumUrl.trim(); - if (trimmed.isEmpty) return ''; - final uri = Uri.tryParse(trimmed); + 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(); @@ -18,17 +25,108 @@ class ICloudAlbumSourceConfig { } bool get isValid { - if (albumUrl.trim().isEmpty) return false; - final uri = Uri.tryParse(albumUrl.trim()); + 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( - albumUrl: (config['album_url'] as String? ?? '').trim(), + albums: cleaned.map(IcloudAlbumLink.new).toList(), ); } - Map toMap() => {'album_url': albumUrl.trim()}; + 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 index 2e919e3..496c2a8 100644 --- a/lib/infrastructure/services/icloud_album_sync_service.dart +++ b/lib/infrastructure/services/icloud_album_sync_service.dart @@ -42,72 +42,143 @@ class ICloudAlbumSyncService implements SyncProvider { bool deleteOrphanedFiles = false, SyncProgressCallback? onProgress, }) async { - _log.info('Starting iCloud album sync (token: ${_config.token})'); + _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 + final shard host - final (photos, host) = await _fetchPhotoList(); - _log.info('Found ${photos.length} photos in iCloud album'); + // 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 (photos.isEmpty) return; + if (albumData.isEmpty) { + if (fetchErrors.isNotEmpty) { + throw ICloudAlbumSyncException( + 'Failed to fetch photo list for all albums', + cause: fetchErrors.first); + } + return; + } - // Step 2: build guid list and checksum→guid mapping for best derivative. - // webasseturls response is keyed by derivative checksum, NOT photoGuid. - final guids = []; + // 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 photo in photos) { - final guid = photo['photoGuid'] as String?; - if (guid == null || guid.isEmpty) continue; - guids.add(guid); - 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 as Map).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; + 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) { - checksumToGuid[bestChecksum] = guid; - guidToChecksum[guid] = bestChecksum; - _log.info('Photo ${guid.substring(0, 8)}: best derivative key=$bestKey (${bestW}x${bestH})'); + 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 — response keys are derivative checksums - final rawUrls = await _fetchAssetUrls(guids: guids, host: host); - _log.info('Got ${rawUrls.length} raw asset URLs for ${guids.length} photos'); - - // Map checksum keys back to photoGuids for file naming + // 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 = {}; - for (final entry in rawUrls.entries) { - final guid = checksumToGuid[entry.key]; - if (guid != null) guidToUrl.putIfAbsent(guid, () => entry.value); + 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'); + } } - _log.info('Matched ${guidToUrl.length} photos with download URLs'); + + 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. @@ -143,6 +214,13 @@ class ICloudAlbumSyncService implements SyncProvider { 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( @@ -168,9 +246,20 @@ class ICloudAlbumSyncService implements SyncProvider { )); } - // Step 6: delete orphaned files if requested (compare by photoGuid) + // 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) { - await _deleteOrphans(localDir, guids.toSet()); + 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'); @@ -180,8 +269,9 @@ class ICloudAlbumSyncService implements SyncProvider { // API: webstream — returns photo metadata + the final shard host // --------------------------------------------------------------------------- - Future<(List>, String)> _fetchPhotoList() async { - final token = _config.token; + Future<(List>, String)> _fetchPhotoList({ + required String token, + }) async { final dio = Dio(); var host = 'sharedstreams.icloud.com'; @@ -256,10 +346,10 @@ class ICloudAlbumSyncService implements SyncProvider { // --------------------------------------------------------------------------- Future> _fetchAssetUrls({ + required String token, required List guids, required String host, }) async { - final token = _config.token; final dio = Dio(); final url = 'https://$host/$token/sharedstreams/webasseturls'; @@ -369,6 +459,30 @@ class ICloudAlbumSyncService implements SyncProvider { 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(); diff --git a/lib/infrastructure/services/json_config_service.dart b/lib/infrastructure/services/json_config_service.dart index a7b45dd..65393b5 100644 --- a/lib/infrastructure/services/json_config_service.dart +++ b/lib/infrastructure/services/json_config_service.dart @@ -322,6 +322,22 @@ 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; + } + + @override + bool get emmcCacheWarmEnabled => _config['emmc_cache_warm_enabled'] ?? false; + + @override + set emmcCacheWarmEnabled(bool value) { + _config['emmc_cache_warm_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 555211a..0adb35a 100644 --- a/lib/infrastructure/services/native_screen_control_service.dart +++ b/lib/infrastructure/services/native_screen_control_service.dart @@ -130,4 +130,39 @@ class NativeScreenControlService { 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 87db24b..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, @@ -77,6 +83,11 @@ class PhotoService extends ChangeNotifier { 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; @@ -244,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 @@ -253,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; @@ -278,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 index 10c22af..60454e5 100644 --- a/lib/infrastructure/services/web_server_service.dart +++ b/lib/infrastructure/services/web_server_service.dart @@ -2,11 +2,16 @@ 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 { @@ -28,10 +33,15 @@ class 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; @@ -106,6 +116,8 @@ class WebServerService { 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'); } @@ -152,6 +164,8 @@ class WebServerService { // Android 'autostart_on_boot': _config.autostartOnBoot, 'keep_alive_enabled': _config.keepAliveEnabled, + 'wifi_adb_enabled': _config.wifiAdbEnabled, + 'emmc_cache_warm_enabled': _config.emmcCacheWarmEnabled, 'auto_update_enabled': _config.autoUpdateEnabled, }; @@ -180,8 +194,10 @@ class WebServerService { // Source setString('active_source', (v) => _config.activeSourceType = v); if (u['icloud_album'] is Map) { - _config.setSourceConfig( - 'icloud_album', Map.from(u['icloud_album'] as 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( @@ -225,6 +241,8 @@ class WebServerService { // 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); + setBool('emmc_cache_warm_enabled', (v) => _config.emmcCacheWarmEnabled = v); setBool('auto_update_enabled', (v) => _config.autoUpdateEnabled = v); await _config.save(); @@ -270,6 +288,230 @@ class WebServerService { _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 // --------------------------------------------------------------------------- @@ -342,9 +584,10 @@ h2{font-size:.75rem;margin:20px 0 10px;color:#bbb;text-transform:uppercase;lette section{background:#1e1e1e;border-radius:8px;padding:16px;margin-bottom:12px} label{display:block;margin-bottom:10px} label>span{display:block;font-size:.8rem;color:#999;margin-bottom:4px} -input[type=url],input[type=text],select{width:100%;padding:8px 10px;background:#2a2a2a; +input[type=url],input[type=text],select,textarea{width:100%;padding:8px 10px;background:#2a2a2a; border:1px solid #444;border-radius:6px;color:#eee;font-size:.9rem} -input[type=url]:focus,input[type=text]:focus,select:focus{outline:none;border-color:#7c5cfc} +input[type=url]:focus,input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:#7c5cfc} +textarea{resize:vertical;font-family:inherit;line-height:1.5} select option{background:#2a2a2a} .row{display:flex;align-items:center;gap:10px;margin-bottom:10px} .row>label{margin:0;flex:1} @@ -399,8 +642,8 @@ button{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-size:
- +
+
+ WiFi ADBEnable ADB over WiFi on port 5555 on every boot (requires root) + +
+
+ Warm system file cache on bootPreload key system files into RAM after boot to reduce crash risk from marginal eMMC sectors (device-specific) + +
Automatic updatesCheck GitHub for new versions @@ -629,7 +880,11 @@ function applyConfig(c){ r.closest('.src-opt').classList.toggle('active',r.value===src); }); showSrcFields(src); - set('icloud-url',(c.icloud_album||{}).album_url||''); + // iCloud album: accept legacy single album_url or album_urls list + const icloudCfg=c.icloud_album||{}; + const icloudUrls=Array.isArray(icloudCfg.album_urls)?icloudCfg.album_urls + :(icloudCfg.album_url?[icloudCfg.album_url]:[]); + set('icloud-url',icloudUrls.join('\n')); set('nc-url',(c.nextcloud_link||{}).url||''); // Slideshow @@ -678,7 +933,9 @@ function applyConfig(c){ // Android setCheck('autostart', c.autostart_on_boot??false); setCheck('keep-alive', c.keep_alive_enabled??false); - setCheck('auto-update', c.auto_update_enabled??false); + setCheck('wifi-adb', c.wifi_adb_enabled??true); + setCheck('emmc-cache-warm', c.emmc_cache_warm_enabled??false); + setCheck('auto-update', c.auto_update_enabled??false); } function applyStatus(s){ @@ -732,9 +989,13 @@ async function saveSettings(){ const src=document.querySelector('input[name=src]:checked')?.value||''; const friSatOn=document.getElementById('fri-sat-on').checked; const friSatTime=friSatOn?parseTime('fri-sat-start'):null; + // iCloud album links: one per line → single legacy field for one link, + // album_urls list for several + const icloudLines=(document.getElementById('icloud-url').value||'').split(/\r?\n/).map(s=>s.trim()).filter(s=>s.length>0); + const icloudPayload=icloudLines.length>1?{album_urls:icloudLines}:{album_url:icloudLines[0]||''}; const p={ active_source:src, - icloud_album:{album_url:document.getElementById('icloud-url').value.trim()}, + icloud_album:icloudPayload, nextcloud_link:{...(cfg.nextcloud_link||{}),url:document.getElementById('nc-url').value.trim()}, slide_duration_seconds:+document.getElementById('slide-dur').value, transition_duration_ms:+document.getElementById('trans-dur').value, @@ -761,6 +1022,8 @@ async function saveSettings(){ use_native_screen_off:document.getElementById('native-off').checked, autostart_on_boot:document.getElementById('autostart').checked, keep_alive_enabled:document.getElementById('keep-alive').checked, + wifi_adb_enabled:document.getElementById('wifi-adb').checked, + emmc_cache_warm_enabled:document.getElementById('emmc-cache-warm').checked, auto_update_enabled:document.getElementById('auto-update').checked, }; try{ 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 0c80774..05d2c09 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -36,8 +36,8 @@ "localFolderSubtitle": "Fotos aus einem lokalen Ordner verwenden", "icloudAlbum": "iCloud Geteiltes Album", "icloudAlbumSubtitle": "Von Apple Photos geteiltem Album synchronisieren", - "icloudAlbumUrl": "iCloud geteiltes Album URL", - "icloudAlbumUrlHint": "https://www.icloud.com/photos/…", + "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": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3ac09a5..3f3cf31 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -36,8 +36,8 @@ "localFolderSubtitle": "Use photos from a local folder", "icloudAlbum": "iCloud Shared Album", "icloudAlbumSubtitle": "Sync from Apple Photos shared album", - "icloudAlbumUrl": "iCloud Shared Album URL", - "icloudAlbumUrlHint": "https://www.icloud.com/photos/…", + "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": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4cceb49..345d4ca 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -287,13 +287,13 @@ abstract class AppLocalizations { /// No description provided for @icloudAlbumUrl. /// /// In en, this message translates to: - /// **'iCloud Shared Album URL'** + /// **'iCloud Shared Album URL(s)'** String get icloudAlbumUrl; /// No description provided for @icloudAlbumUrlHint. /// /// In en, this message translates to: - /// **'https://www.icloud.com/photos/…'** + /// **'https://www.icloud.com/photos/…\nOne link per line'** String get icloudAlbumUrlHint; /// No description provided for @icloudAlbumUrlInvalid. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index feccb17..e2a5552 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -108,10 +108,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Von Apple Photos geteiltem Album synchronisieren'; @override - String get icloudAlbumUrl => 'iCloud geteiltes Album URL'; + String get icloudAlbumUrl => 'iCloud geteilte Album-URLs'; @override - String get icloudAlbumUrlHint => 'https://www.icloud.com/photos/…'; + String get icloudAlbumUrlHint => + 'https://www.icloud.com/photos/…\nEin Link pro Zeile'; @override String get icloudAlbumUrlInvalid => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7700863..e01dcdf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -106,10 +106,11 @@ class AppLocalizationsEn extends AppLocalizations { String get icloudAlbumSubtitle => 'Sync from Apple Photos shared album'; @override - String get icloudAlbumUrl => 'iCloud Shared Album URL'; + String get icloudAlbumUrl => 'iCloud Shared Album URL(s)'; @override - String get icloudAlbumUrlHint => 'https://www.icloud.com/photos/…'; + String get icloudAlbumUrlHint => + 'https://www.icloud.com/photos/…\nOne link per line'; @override String get icloudAlbumUrlInvalid => diff --git a/lib/main.dart b/lib/main.dart index 4f49c4d..cae8c69 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,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); diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 290b752..1155094 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -20,6 +20,7 @@ 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'; +import '../../infrastructure/services/emmc_cache_warm_service.dart'; import '../../infrastructure/services/native_screen_control_service.dart'; import '../../infrastructure/services/keep_alive_service.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -60,6 +61,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse late bool _deleteOrphanedFiles; late bool _autostartOnBoot; late bool _keepAliveEnabled; + late bool _emmcCacheWarmEnabled; late bool _autoUpdateEnabled; late bool _autoUpdateSilent; bool _isDeviceOwner = false; @@ -111,11 +113,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse String? _selectedAlbumId; bool _isLoadingAlbums = false; - // Track original values to detect changes - late String _originalSyncType; - late WebDavSourceConfig _originalWebDavSourceConfig; - late String _originalICloudAlbumUrl; - @override void initState() { super.initState(); @@ -143,6 +140,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse _deleteOrphanedFiles = config.deleteOrphanedFiles; _autostartOnBoot = config.autostartOnBoot; _keepAliveEnabled = config.keepAliveEnabled; + _emmcCacheWarmEnabled = config.emmcCacheWarmEnabled; _autoUpdateEnabled = config.autoUpdateEnabled; _autoUpdateSilent = config.autoUpdateSilent; if (Platform.isAndroid) { @@ -223,14 +221,10 @@ class _SettingsScreenState extends State with WidgetsBindingObse final icloudConfig = ICloudAlbumSourceConfig.fromMap( config.getSourceConfig('icloud_album'), ); - _icloudAlbumUrlController = TextEditingController(text: icloudConfig.albumUrl) + _icloudAlbumUrlController = + TextEditingController(text: icloudConfig.linksText) ..addListener(() => setState(() {})); - // Store original values for comparison on save - _originalSyncType = _syncType; - _originalWebDavSourceConfig = nextcloudConfig; - _originalICloudAlbumUrl = icloudConfig.albumUrl; - // Load saved album selection for device_photos mode final devicePhotosConfig = config.getSourceConfig('device_photos'); _selectedAlbumId = devicePhotosConfig['albumId'] as String?; @@ -316,22 +310,12 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Detect if sync configuration changed final newNextcloudUrl = _nextcloudUrlController.text.trim(); - final newICloudAlbumUrl = _icloudAlbumUrlController.text.trim(); + final newICloudAlbumLinks = _icloudAlbumUrlController.text; + final newICloudAlbumConfig = + ICloudAlbumSourceConfig.fromLinkText(newICloudAlbumLinks); final newWebDavSourceConfig = _buildWebDavSourceConfig( url: newNextcloudUrl, ); - final nextcloudConfigChanged = - !_nextcloudConfigsEqual(newWebDavSourceConfig, _originalWebDavSourceConfig); - final icloudConfigChanged = newICloudAlbumUrl != _originalICloudAlbumUrl; - final syncConfigChanged = - _syncType != _originalSyncType || - (_syncType == 'nextcloud_link' && nextcloudConfigChanged) || - (_syncType == 'icloud_album' && icloudConfigChanged); - final newSyncSourceConfigured = syncConfigChanged && ( - (_syncType == 'nextcloud_link' && newNextcloudUrl.isNotEmpty) || - (_syncType == 'icloud_album' && ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).isValid) - ); - config.slideDurationSeconds = _slideDurationSeconds; config.transitionDurationMs = (_transitionDurationSeconds * 1000).round(); config.blurBorders = _blurBorders; @@ -350,6 +334,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse config.deleteOrphanedFiles = _deleteOrphanedFiles; config.autostartOnBoot = _autostartOnBoot; config.keepAliveEnabled = _keepAliveEnabled; + config.emmcCacheWarmEnabled = _emmcCacheWarmEnabled; config.autoUpdateEnabled = _autoUpdateEnabled; config.autoUpdateSilent = _autoUpdateSilent; config.showClock = _showClock; @@ -378,9 +363,12 @@ 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); + + // Sync eMMC cache warm setting to SharedPreferences for ScreenControlHandler + await EmmcCacheWarmService.setEnabled(_emmcCacheWarmEnabled); if (_syncType == 'nextcloud_link') { config.setSourceConfig('nextcloud_link', newWebDavSourceConfig.toMap()); @@ -388,7 +376,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (_syncType == 'icloud_album') { config.setSourceConfig( 'icloud_album', - ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).toMap(), + newICloudAlbumConfig.toMap(), ); } @@ -399,7 +387,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse final sourceIsConfigured = (_syncType == 'nextcloud_link' && newNextcloudUrl.isNotEmpty) || (_syncType == 'icloud_album' && - ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).isValid); + newICloudAlbumConfig.isValid); if (sourceIsConfigured) { final photoService = context.read(); photoService.triggerSync(); // fire-and-forget @@ -707,6 +695,47 @@ class _SettingsScreenState extends State with WidgetsBindingObse 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), @@ -753,6 +782,20 @@ class _SettingsScreenState extends State with WidgetsBindingObse }, ), + const SizedBox(height: 8), + + SwitchListTile( + title: const Text('Warm system file cache on boot'), + subtitle: const Text( + 'Reads key system files into RAM after boot to reduce crash risk from marginal eMMC sectors. Enable only on affected devices.', + ), + secondary: const Icon(Icons.memory), + value: _emmcCacheWarmEnabled, + onChanged: (value) { + setState(() => _emmcCacheWarmEnabled = value); + }, + ), + const SizedBox(height: 8), _buildAutoUpdateSection(), @@ -986,6 +1029,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse const SizedBox(height: 8), TextField( controller: _icloudAlbumUrlController, + maxLines: null, decoration: InputDecoration( hintText: l10n.icloudAlbumUrlHint, border: const OutlineInputBorder(), @@ -994,15 +1038,24 @@ class _SettingsScreenState extends State with WidgetsBindingObse ), const SizedBox(height: 4), Builder(builder: (ctx) { - final url = _icloudAlbumUrlController.text.trim(); - final valid = url.isEmpty || - ICloudAlbumSourceConfig(albumUrl: url).isValid; - return valid - ? const SizedBox.shrink() - : Text(l10n.icloudAlbumUrlInvalid, + 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)); + fontSize: 12)), + for (final link in invalidLinks) + Text(link, + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 11)), + ], + ); }), ], ), @@ -1694,34 +1747,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 diff --git a/scripts/fix-frameo.sh b/scripts/fix-frameo.sh new file mode 100755 index 0000000..d2f5ffb --- /dev/null +++ b/scripts/fix-frameo.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Reconnects to the Frameo after a reboot and stops the buggy Rockchip RK3326 +# memtrack HAL service. Run this with the USB cable plugged into the Frameo. +# +# The app (OpenPhotoFrame) does this automatically on every boot via su, so +# this script is only needed if the app isn't installed or hasn't launched yet. +# +# Usage: ./scripts/fix-frameo.sh + +set -uo pipefail + +ADB="${HOME}/Library/Android/sdk/platform-tools/adb" +SERIAL="SERIAL_REDACTED" +FRAMEO_IP="192.168.0.0" + +echo "==> Waiting for Frameo USB ADB connection..." +"${ADB}" -s "${SERIAL}" wait-for-device + +echo "==> Stopping buggy memtrack HAL (Rockchip RK3326 firmware bug)..." +"${ADB}" -s "${SERIAL}" shell stop vendor.memtrack-hal-1-0 +STATUS=$("${ADB}" -s "${SERIAL}" shell getprop init.svc.vendor.memtrack-hal-1-0) +echo " vendor.memtrack-hal-1-0: ${STATUS}" + +echo "==> Removing unwanted system apps (idempotent — safe to re-run)..." +# com.adups.fota has android:persistent="true" so pm disable is ignored — use uninstall instead. +# net.frameo.frame ships in /system/priv-app/Frameo.apk; pm uninstall only strips the data-layer +# overlay and falls back to the ROM copy, which then self-updates and reboots the device. +# pm disable-user fully disables the package for user 0, blocking both the ROM copy and any update. +disable_pkg() { + local result + result=$("${ADB}" -s "${SERIAL}" shell pm disable-user --user 0 "$1" 2>&1) + case "${result}" in + *disabled*) echo " $1: disabled" ;; + *) echo " $1: ${result}" ;; + esac + # Also uninstall the data-layer overlay if present + "${ADB}" -s "${SERIAL}" shell pm uninstall -k --user 0 "$1" >/dev/null 2>&1 || true +} +disable_pkg com.adups.fota # OTA updater, android:persistent="true" +disable_pkg android.rockchip.update.service # Rockchip OTA +disable_pkg net.frameo.frame # Frameo photo app — has ROM copy that self-updates and reboots +disable_pkg com.cghs.stresstest # Rockchip factory stress test, android:persistent="true", triggers reboots +disable_pkg com.DeviceTest # Rockchip factory device test +"${ADB}" -s "${SERIAL}" shell am force-stop com.adups.fota 2>/dev/null || true +"${ADB}" -s "${SERIAL}" shell am force-stop android.rockchip.update.service 2>/dev/null || true +"${ADB}" -s "${SERIAL}" shell am force-stop net.frameo.frame 2>/dev/null || true + +echo "==> Enabling WiFi ADB on port 5555..." +"${ADB}" -s "${SERIAL}" tcpip 5555 +sleep 2 + +echo "==> Connecting via WiFi..." +"${ADB}" connect "${FRAMEO_IP}:5555" +echo "" +echo "Done. You can unplug the USB cable." +echo "WiFi ADB: ${ADB} -s ${FRAMEO_IP}:5555 shell" From 6b57dc989c0d7e856a0a1aa03910301d1bda3099 Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Sat, 19 Sep 2026 18:08:14 +1000 Subject: [PATCH 4/7] Replace the 106K script with a PX-110 one and trim the device notes The Frameo 106K is retired, so its tooling and notes were mostly dead weight. - Delete scripts/fix-frameo.sh. It hardcoded the 106K's serial and IP, stopped a Rockchip memtrack HAL that does not exist on the PX-110, and disabled a package list that no longer matches - Add scripts/px110.sh: idempotent setup and health check. Connects over Wi-Fi (falling back to USB and re-enabling TCP mode), repairs the stock-service config, pins the app as default launcher, and reports health. --health reports without changing anything; --install deploys the APK after checking it has an armeabi-v7a slice - Restructure FRAMEO.md around the PX-110 as the current device and compress the 106K to what still transfers: the eMMC failure mode and how to map a bad sector to a file, the ADB factory reset, and why the two Rockchip-only app workarounds stay in the tree. 537 -> 313 lines - Drop the Wi-Fi/BT MAC from the notes. It is a stable hardware fingerprint with no operational use, and this repo has a public remote The script checks for the adups respawn loop and orphaned scan helpers specifically, since those are the two regressions that took the frame down. --- FRAMEO.md | 614 ++++++++++++++---------------------------- scripts/fix-frameo.sh | 56 ---- scripts/px110.sh | 194 +++++++++++++ 3 files changed, 389 insertions(+), 475 deletions(-) delete mode 100755 scripts/fix-frameo.sh create mode 100755 scripts/px110.sh diff --git a/FRAMEO.md b/FRAMEO.md index 27b53d9..05b294b 100644 --- a/FRAMEO.md +++ b/FRAMEO.md @@ -1,223 +1,52 @@ -# Frameo device notes +# Photo frame device notes -Covers two frames: the Frameo 106K (retired, returned to Amazon) below, and the -Pexar Frame PX-110 that replaced it — see [Pexar Frame PX-110](#pexar-frame-px-110-mediatek-mt8167). +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). -Device: Frameo 106K (10" photo frame) -SoC: Rockchip RK3326 -Android: 11 (user build, `ro.debuggable=1`, SELinux permissive) -IP: 192.168.0.0 -USB serial: `SERIAL_REDACTED` +## Pexar Frame PX-110 (MediaTek MT8167) -## Rockchip RK3326 memtrack HAL crash loop +| | | +|---|---| +| 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 | `FRAME_SERIAL_REDACTED` | +| IP | 192.168.0.0 (was .141 — it moves on DHCP, check before assuming) | -### What happens - -The `android.hardware.memtrack@1.0-service` HAL crashes on every boot within the first 20 seconds. The crash is a `FORTIFY: readdir: null DIR*` abort inside `find_dir()` in `memtrack.rk3326.so` — a firmware bug in the Rockchip RK3326 vendor library. - -Because the service has no `oneshot` flag in its RC file, Android init restarts it after each crash. Left unchecked, the crash loop consumes CPU, floods the crash log, and eventually destabilises the system (system_server dies, device becomes unresponsive). - -The memtrack HAL only feeds diagnostic tools (memory profiling). Stopping it has no effect on normal operation. - -### Automatic fix (app-based) - -OpenPhotoFrame stops the service automatically on every boot. On `BOOT_COMPLETED`, `ScreenControlHandler.stopCrashingMemtrackService()` runs: - -```kotlin -Runtime.getRuntime().exec( - arrayOf("/system/xbin/su", "0", "/system/bin/stop", "vendor.memtrack-hal-1-0") -) -``` - -This works because: -- SELinux is permissive on this device — domain transitions are logged but not blocked -- The app process has `NoNewPrivs=0` and `Seccomp=0` — the setuid `su` binary gains root -- `stop` marks the service disabled in init; it will not restart until the next reboot - -The fix only runs on Rockchip devices (detected via `Build.HARDWARE` containing `"rk"` or the presence of `/vendor/lib/hw/memtrack.rk3326.so`). - -### Manual fallback - -If the app hasn't launched yet or isn't installed, run the script with the USB cable plugged in: - -``` -./scripts/fix-frameo.sh -``` - -This stops the service via ADB root shell and re-enables WiFi ADB. - -### Why a permanent firmware fix isn't possible - -The RC file that needs `oneshot` added lives at `/vendor/etc/init/android.hardware.memtrack@1.0-service.rc`. The vendor partition is dm-verity protected: - -- Direct writes to `/vendor` are blocked -- `adb disable-verity` fails (requires a userdebug build) -- `adb remount` sets up overlayfs, but the upper layer is a tmpfs at `/mnt/scratch` — not backed by persistent storage, so changes are lost on reboot -- There is no scratch partition in the partition table and no `/data/gsi/scratch.img` - -Magisk would solve this (it injects scripts from `/data/adb/` before HAL services start) but requires flashing a custom boot image. - -## OTA update reboots (adups FOTA) - -### What happens - -The device ships with `com.adups.fota` (Rockchip's firmware update service) and `android.rockchip.update.service`. Both register `RTC_WAKEUP` alarms and can trigger a clean `reboot` when they decide to check for or install firmware. The reboot leaves no crash trace — `sys.boot.reason` is simply `reboot` — making it look like a mystery crash. - -Five services have been disabled for user 0: +Root works despite `ro.debuggable=0`, because `/system/xbin/su` is setuid root and +SELinux is permissive: ```sh -adb shell pm disable-user --user 0 com.adups.fota -adb shell pm disable-user --user 0 android.rockchip.update.service -adb shell pm disable-user --user 0 net.frameo.frame -adb shell pm disable-user --user 0 com.cghs.stresstest -adb shell pm disable-user --user 0 com.DeviceTest -``` - -**Important — use `pm disable-user`, not `pm uninstall`.** - -`pm uninstall -k --user 0` only removes the `/data/app/` update overlay. If the app ships in `/system/priv-app/` or `/system/app/`, Android silently reverts to that ROM copy, which then runs normally. `net.frameo.frame` lives at `/system/priv-app/Frameo.apk` — when the overlay was uninstalled, the ROM copy (v1.30.13) became active again, connected to Frameo's servers, downloaded an update, and triggered a clean `reboot` every 2–4 hours. - -`pm disable-user --user 0` marks the package as `COMPONENT_ENABLED_STATE_DISABLED_USER` (enabled=3), which blocks both the ROM copy and any future updates from running. - -`com.adups.fota` and `com.cghs.stresstest` are declared `android:persistent="true"`. Android's `ActivityManagerService` ignores `pm disable` for persistent apps — `pm disable` alone is not enough. `pm disable-user --user 0` still works because it marks the package as disabled for the user before ActivityManager evaluates persistence. - -`com.cghs.stresstest` is a Rockchip factory stress-test tool that runs on a schedule and is designed to reboot the device as part of its test cycle. This was causing the ~3 hour reboot cycle observed after OTA services were removed. - -If the device starts rebooting unexpectedly again, check `sys.boot.reason`. If it says `reboot` rather than `kernel_panic` or `watchdog`, an OTA service is likely responsible. Re-run the `pm uninstall` commands above. - -## WiFi ADB - -WiFi ADB (TCP mode) does not persist across reboots. To re-enable it after a reboot, plug in USB and run: - -``` -./scripts/fix-frameo.sh -``` - -Or manually: - -```sh -adb -s SERIAL_REDACTED tcpip 5555 -adb connect 192.168.0.0:5555 -``` - -## eMMC failure and device retirement - -### What happened - -The Frameo 106K developed progressive eMMC (internal flash storage) failure over its life as a photo frame running OpenPhotoFrame. - -**First crash (system partition):** Bad sectors appeared in the system partition (`mmcblk2p4`/`mmcblk2p6`), causing a kernel I/O error that brought down the device. The affected sectors mapped to system binaries (`statsd`, `audioserver`, `netd`, `wificond`, etc.). - -**Mitigation — eMMC cache warming:** OpenPhotoFrame added a boot-time feature (toggleable in settings, off by default) that reads the affected system files into the page cache on startup. This prevents the kernel from needing to re-read those sectors from eMMC while daemons are running. Implemented in `ScreenControlHandler.warmEmmcFileCache()`. - -**Second crash (userdata partition, ~1 hour later):** 13 new bad sectors appeared in `mmcblk2p15` (userdata, f2fs on `dm-6`). These mapped to: -- 4 JPEG photo files in OpenPhotoFrame's cache directory -- `IpMemoryStore.db` (network memory — not critical) -- f2fs node inode blocks (filesystem metadata — serious) - -The node inode damage indicated the f2fs inode table itself was at risk, meaning the filesystem could become unmountable on the next crash. - -### Diagnosis tools used - -```sh -# Identify which partition a bad sector lives in -adb shell su 0 cat /proc/partitions -adb shell su 0 cat /sys/kernel/debug/mmc0/mmc0:0001/ext_csd # eMMC health - -# Map a bad sector to a file on f2fs -# (sector number from dmesg / Prometheus mmc_error_count metric) -# userdata starts at sector 6863612 on mmcblk2 -# f2fs block = (bad_sector - partition_start) / 8 -adb shell su 0 dump.f2fs -b /dev/block/dm-6 - -# Find filename from inode number -adb shell su 0 find /data -inum -``` - -### Outcome - -The eMMC failure region was spreading to new partitions. The device was beyond reliable software mitigation. Decision: factory reset and return to Amazon under hardware failure warranty. - -eMMC failure is a documented failure mode for Frameo-brand devices. Community reports: -- https://forums.justuseapp.com/en/post/QERMOAEXL7/frameo-keeps-rebooting-over-and-over -- https://www.justanswer.com/electronics/tksy5-frameo-digital-photo-frame-stuck-rebooting.html -- https://www.devicepitfalls.com/frameo-stuck-on-startup-screen/ - -### Factory reset procedure - -The recovery UI had only one physical button which activated the first menu item ("Reboot system now"), making menu navigation impossible. Factory reset was triggered via ADB: - -```sh -# Write wipe command to recovery command file -adb -s SERIAL_REDACTED shell su 0 sh -c \ - 'mkdir -p /cache/recovery && printf "--wipe_data\n" > /cache/recovery/command && sync' - -# Reboot into recovery — it reads the command file and wipes automatically -adb -s SERIAL_REDACTED reboot recovery -``` - -After reset: `net.frameo.frame` (stock Frameo) present, `io.github.micw.openphotoframe` gone, `/data/media/0/frameo_files/media/` empty. Device verified clean and returned to Amazon. - -## ADB quick reference - -```sh -# USB -adb -s SERIAL_REDACTED shell - -# WiFi (after enabling TCP mode) -adb -s 192.168.0.0:5555 shell - -# Deploy APK -adb -s 192.168.0.0:5555 install -r build/app/outputs/flutter-apk/app-release.apk - -# Check memtrack service status -adb -s 192.168.0.0:5555 shell getprop init.svc.vendor.memtrack-hal-1-0 - -# Stop memtrack manually (as root) -adb -s SERIAL_REDACTED shell stop vendor.memtrack-hal-1-0 +adb shell /system/xbin/su 0 id # uid=0(root) ... context=u:r:su:s0 ``` -# Pexar Frame PX-110 (MediaTek MT8167) +Setup and health checks are scripted — see [scripts/px110.sh](scripts/px110.sh). -Replacement for the retired Frameo 106K. Different vendor and SoC, so none of the -Rockchip notes above apply — there is no memtrack HAL and no adups FOTA package. +### Build constraint: 32-bit only -Device: Pexar Frame, `ro.product.brand=Lexar`, `ro.product.device=dpf1106_mk_32` -SoC: MediaTek MT8167 (`ro.board.platform=mt8167`), 32-bit only (`armeabi-v7a`) -Android: 11 (user build, `ro.debuggable=0`, SELinux permissive) -RAM: 2 GB -USB serial: `FRAME_SERIAL_REDACTED` -IP: 192.168.0.0 - -Root works despite `ro.debuggable=0`: `/system/xbin/su` is setuid root and SELinux -is permissive. +`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 -adb shell /system/xbin/su 0 id # uid=0(root) ... context=u:r:su:s0 +unzip -l build/app/outputs/flutter-apk/app-release.apk | grep -o 'lib/[^/]*' | sort -u +# must include lib/armeabi-v7a ``` -## Build constraint: 32-bit only - -`ro.product.cpu.abilist` is `armeabi-v7a,armeabi` — there is no arm64 slice. The -default `flutter build apk --release` fat APK works because it bundles -`armeabi-v7a` alongside `arm64-v8a` and `x86_64`. Building arm64-only -(`--target-platform android-arm64`) or picking the wrong split from -`--split-per-abi` produces an APK this frame cannot run. Check before deploying: +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 -unzip -l build/app/outputs/flutter-apk/app-release.apk | grep -o 'lib/[^/]*' | sort -u -# must include lib/armeabi-v7a +flutter config --jdk-dir "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home" ``` -The two Rockchip-specific workarounds in the app are inert here, as intended: -`stopCrashingMemtrackService()` gates on `Build.HARDWARE` containing `"rk"` -(this device reports `mt8167`) and on `/vendor/lib/hw/memtrack.rk3326.so`, which -does not exist. The eMMC cache-warming feature is off by default and targets the -106K's failing sectors, so leave it off. +That is a machine-local setting, not repo state. Undo with `flutter config --jdk-dir=`. -## Vendor install lock (`ro.vendor.custom_recover`) - -### What happens +### Vendor install lock (`ro.vendor.custom_recover`) Out of the box every sideload fails, including as root: @@ -225,8 +54,8 @@ 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`) to gate installs on a property: +The ROM patches `PackageManagerService.preparePackageLI` in +`/system/framework/services.jar`: ```java if ("1".equals(SystemProperties.get("ro.vendor.custom_recover", "0"))) { @@ -236,106 +65,57 @@ if ("1".equals(SystemProperties.get("ro.vendor.custom_recover", "0"))) { } ``` -`MtkSystemUI` reads the same property from `CommandQueue.isRecoveryFirstBoot()`, and -`panelsEnabled()` returns false while it is `1`. So the flag is a kiosk lockdown -switch: it blocks sideloading *and* disables the notification shade. - -### Where the value comes from - -`/vendor/bin/nvram_daemon` publishes it. The relevant code (Thumb-2, at `0x2eb4`) -reads the 1024-byte product-info record, takes the byte at record offset `0x3FE`, -formats it, and calls `property_set("ro.vendor.custom_recover", )`. +`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. -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 you have the right partition: +`/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 "FRAME_SERIAL_REDACTED" -# offset 0x6e Wi-Fi/BT MAC 84:5d:d7:0b:ce:85 +# 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. +**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 -P=/dev/block/platform/soc/11120000.mmc/by-name/proinfo 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: - -```sh -adb shell "/system/xbin/su 0 dd if=$P of=/data/local/tmp/proinfo.bak bs=4096" -adb pull /data/local/tmp/proinfo.bak backups/proinfo-FRAME_SERIAL_REDACTED.bak -``` - -A pre-change backup sits at `backups/proinfo-FRAME_SERIAL_REDACTED.bak`, untracked -(sha256 `2dbf2e37f726ce7e2a8417469c5ad00517f86113b248a1d22809d8ca62ed651e`). Keep it -somewhere safe — restoring it needs a frame that still boots. - -Side effect, as expected from `panelsEnabled()`: the notification shade and quick -settings now pull down. Restoring the byte reverts both effects. - -### Why not patch the system partition instead - -`/` 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 an I/O -error (harmless — the flag resets at reboot). `adb disable-verity` and `adb remount` -both need a userdebug build. Installing the app into `/system/app` would therefore -risk an unbootable frame; the `proinfo` byte avoids verity entirely. - -## Flaky USB - -USB ADB re-enumerates constantly on this frame — the transport id climbed past 100 -in one session, and commands die mid-run with `device not found`. It is not -rebooting; check `/proc/uptime` to confirm it keeps climbing. Switch to Wi-Fi ADB -and leave the cable out. - -## Wi-Fi ADB - -As on the old frame, TCP mode does not survive a reboot. Plug in USB and run: - -```sh -adb -s FRAME_SERIAL_REDACTED tcpip 5555 -adb connect 192.168.0.0:5555 -``` - -## ADB quick reference +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. -```sh -# USB -adb -s FRAME_SERIAL_REDACTED shell +Side effect, as predicted by `panelsEnabled()`: the notification shade now pulls +down. Restoring the byte reverts both effects. -# Wi-Fi -adb -s 192.168.0.0:5555 shell +**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. -# Deploy (needs the install lock cleared first) -adb -s 192.168.0.0:5555 install -r -g build/app/outputs/flutter-apk/app-release.apk +### Stock services -# Launch -adb -s 192.168.0.0:5555 shell monkey -p io.github.micw.openphotoframe \ - -c android.intent.category.LAUNCHER 1 -``` +Surveyed 2026-09-15. Never `pm uninstall` these — Android falls back to the ROM copy, +which then runs and self-updates. -## Stock service survey - -Surveyed 2026-09-15. Three of the five packages disabled on the Frameo 106K are -present here; `com.cghs.stresstest` and `android.rockchip.update.service` are not. -Never `pm uninstall` these — see the reasoning under -[OTA update reboots](#ota-update-reboots-adups-fota). But **do not -`pm disable-user` a package marked `android:persistent="true"` on this ROM -either**: that caused a four-day outage, described in -[The 2026-09-19 respawn-loop outage](#the-2026-09-19-respawn-loop-outage). The -106K note claiming `disable-user` copes with persistent apps does not hold here. +**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: @@ -346,8 +126,7 @@ 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 instead, so ActivityManager's persistent process starts once and then -idles with nothing left to trigger it: +components, so the persistent process starts once and idles with nothing to trigger: ```sh for c in .receiver.MyReceiver .service.FcmService .GoogleOtaClient \ @@ -357,181 +136,178 @@ for c in .receiver.MyReceiver .service.FcmService .GoogleOtaClient \ done ``` -Afterwards `com.adups.fota` must report `enabled=0` or `enabled=1` -(default/enabled) — **not** `enabled=3`. Its `Application.onCreate` still -re-registers a daily `RTC_WAKEUP`, but that alarm's `PendingIntent` targets the -now-disabled `MyReceiver`, so it fires into nothing. - -### com.adups.fota v5.30 — active reboot risk +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 -At `/product/app/FotaApp/`. Same OTA updater that rebooted the 106K every 2–4 hours. -Flags `SYSTEM PERSISTENT`; holds `REBOOT`, `RECOVERY`, `SCHEDULE_EXACT_ALARM`, -`RECEIVE_BOOT_COMPLETED`, `WAKE_LOCK`. It had a daily `RTC_WAKEUP` queued -(`*walarm*:com.adups.fota.custom_service`). - -Unlike the Rockchip build, this one also registers Firebase Cloud Messaging -(`.service.FcmService`, `FirebaseMessagingService`) alongside `.GoogleOtaClient`, so -an OTA can be pushed remotely at any time rather than only on the daily poll. Nothing -had downloaded yet — `/data/data/com.adups.fota/files` and `/data/ota_package` were -still at the factory date, and `/cache/recovery/command` was absent. - -### com.DeviceTest — latent boot-loop - -At `/system/app/HCNDeviceTest/`. Shares the **system UID** (`userId=1000`) and holds -`REBOOT`, `MASTER_CLEAR` and `DEVICE_POWER`. Starts on `BOOT_COMPLETED` via -`com.DeviceTest.BootReceiver`, which is the factory burn-in trigger: +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: -```java -SharedPreferences sp = context.getSharedPreferences("AgingTest", 0); -if (!sp.getBoolean("istestend", true)) { // default TRUE - Intent i = new Intent(context, com.DeviceTest.AgingTest.class); - i.setFlags(FLAG_ACTIVITY_NEW_TASK); - i.putExtra("Reboot", "reboot"); - context.startActivity(i); -} +```sh +adb shell /system/xbin/su 0 cmd package set-home-activity \ + io.github.micw.openphotoframe/.MainActivity ``` -The default is `true`, so an absent flag leaves the soak test dormant — and -`/data/data/com.DeviceTest/shared_prefs/` was empty, so it had never armed. The risk -is latent: anything that runs the factory test and writes `istestend=false` would -launch `AgingTest` with a reboot extra on every subsequent boot. It also ships -`RecoveryTestService` and `TestService`. - -This is the structural equivalent of `com.cghs.stresstest` on the 106K, which is -worth knowing if a future frame reboots on a fixed cycle: check the aging-test prefs -before assuming OTA. - -### net.frameo.frame v1.26.26 — display conflict, not reboots - -At `/system/system_ext/priv-app/Frameo/`, `PRIVILEGED`. Unlike the 106K copy it does -**not** hold `REBOOT`, so it is not a reboot vector here. It does hold `SET_TIME` and -had an `RTC_WAKEUP` for `.utils.StandbyBroadcastReceiver`, which manages the screen -and would fight OpenPhotoFrame for the display. It is also a self-update vector. - -Disabling it is safe on this device because two other HOME activities remain: +Verify with the MAIN action included — `-c HOME` alone reports "No activity found" +even when the default is set correctly: -``` -com.android.launcher3/.uioverrides.QuickstepLauncher -io.github.micw.openphotoframe/.MainActivity <- app declares itself HOME -com.android.settings/.FallbackHome +```sh +adb shell cmd package resolve-activity \ + -a android.intent.action.MAIN -c android.intent.category.HOME --brief ``` -### Left enabled +### Wi-Fi ADB and flaky USB -- `com.debug.loggerui` + the `aee_aed` / `aee_aedv` / `mobile_log_d` daemons. Only - boot-receiver capability, no reboot vector. Combined on-disk footprint was ~530 KB - (`/data/debuglogger` 11K, `/data/aee_exp` 365K, `/data/anr` 3.5K, - `/data/tombstones` 156K) with `/data` at 5% of 26 GB — no eMMC wear pressure, in - contrast to the 106K. Worth re-checking with `du -sh` if storage creeps up. -- `com.mediatek.engineermode` — holds `MASTER_CLEAR` but only launches from the UI. -- `com.mediatek.miravision.ui`, `com.mediatek.callrecorder`, - `com.mediatek.lbs.em2.ui`, `com.mediatek.capctrl.service` — no reboot vectors. +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. -### Checking for a regression +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 shell dumpsys alarm | grep -c "RTC_WAKEUP #" # expect 0 -adb shell dumpsys package com.adups.fota | grep -m1 enabled= # expect enabled=3 -adb shell getprop sys.boot.reason +adb -s FRAME_SERIAL_REDACTED tcpip 5555 +adb connect 192.168.0.0:5555 ``` -A `sys.boot.reason` of plain `reboot` (rather than `reboot,shell` from your own -`adb reboot`, or `kernel_panic` / `watchdog`) points at one of the above waking up. - -## The 2026-09-19 respawn-loop outage +### ADB quick reference -The frame wedged showing "process system isn't responding" with dead touch input. -Prometheus stopped scraping at 07:31; the device never rebooted (uptime ran -continuously through the whole event). - -### Cause 1 — `pm disable-user` on a persistent package +```sh +# Wi-Fi (preferred — USB is unreliable) +adb -s 192.168.0.0:5555 shell -`com.adups.fota` is `android:persistent="true"`. Disabling it with -`pm disable-user` on 2026-09-15 meant ActivityManager kept respawning the -persistent process, it died immediately because the package was disabled, and AMS -respawned it again — **17 times per minute, ~24,000 per day, for four days**: +# Deploy (needs the install lock cleared first) +adb -s 192.168.0.0:5555 install -r -g build/app/outputs/flutter-apk/app-release.apk -``` -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 +# Metrics without adb +curl -s http://192.168.0.0:8080/metrics | grep ^opf_uptime_seconds ``` -Re-enabling the package stops the loop immediately. Use the component-level -disable in [Stock service survey](#stock-service-survey) instead. +### The 2026-09-19 respawn-loop outage -### Cause 2 — unbounded process-scan pile-up in the app +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. -`WebServerService._refreshProcessStatus()` shelled out per metrics scrape with +**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**: ``` -su 0 sh -c "for f in /proc/[0-9]*/cmdline; do tr '\0' '\n' < $f | head -1; done" +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 ``` -which forks two processes per PID — about 520 per scan on this device. Worse, the -throttle timestamp was assigned *after* the `await` with no in-flight guard, so a -single slow scan meant every later scrape launched another 520-fork scan on top of -the stuck one. An orphaned `tr` was found spinning at 100% of a core. +Re-enabling the package stops it immediately. -Fixed by switching to one `su 0 ps -A -o NAME`, adding a 10s timeout, setting the -throttle before the await, and adding an in-flight guard. +**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 presented as a system hang +**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. -Sustained ~90% system CPU starved everything. `system_server` ANR'd, and its ANR -handler then jammed: thread `AnrConsumer` blocked in `debuggerd_trigger_dump` → -`recvfrom` while holding MediaTek's `AnrManagerService$AnrDumpRecord` lock, so -every subsequent ANR queued behind it. Both `system_server` and the app showed -*idle* main threads (`epoll_wait` / `nativePollOnce`) — the hang was CPU -starvation plus a wedged ANR pipeline, not a deadlock. - -### Diagnostic notes for next time +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 throughout even while the UI was - frozen — always try `adb connect` and `curl :8080/metrics` before power-cycling. -- ANR traces live in `/data/anr/` (`anr_*` for the report, `trace_*` for the dumped - process). They rotate quickly; grab them early. + `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). Nothing resembling the 106K's hardware failure. - -## Building - -Flutter 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: + dmesg), storage (25 GB free). -```sh -flutter config --jdk-dir "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home" -``` +## Previous device: Frameo 106K (retired) -This is a machine-local Flutter setting, not repo state. Undo with -`flutter config --jdk-dir=`. +Rockchip RK3326, Android 11, USB serial `SERIAL_REDACTED`. Returned to Amazon after +progressive eMMC failure. Kept for the parts that transfer. -## Making the app the default launcher +**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/). -With `net.frameo.frame` disabled there are still two HOME candidates -(`com.android.launcher3` and the app), and no default was set — so the frame risked -booting to a launcher chooser. Pin it: +Mapping a bad sector to a file, if it happens again: ```sh -adb shell /system/xbin/su 0 cmd package set-home-activity \ - io.github.micw.openphotoframe/.MainActivity +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 ``` -Verify with the MAIN action included; `-c HOME` alone reports "No activity found" -even when the default is set correctly: +**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 cmd package resolve-activity \ - -a android.intent.action.MAIN -c android.intent.category.HOME --brief +adb shell su 0 sh -c 'mkdir -p /cache/recovery && printf -- "--wipe_data\n" > /cache/recovery/command && sync' +adb reboot recovery ``` -The setting survives reboot. Combined with the app's `autostart_on_boot` and -`wifi_adb_enabled` config flags, a cold boot reaches photos in about 30 seconds and -brings Wi-Fi ADB back on its own — the app re-runs `setprop service.adb.tcp.port -5555` and restarts `adbd` (`ScreenControlHandler.kt`). That self-healing matters -because USB on this frame is unreliable. +**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/scripts/fix-frameo.sh b/scripts/fix-frameo.sh deleted file mode 100755 index d2f5ffb..0000000 --- a/scripts/fix-frameo.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# Reconnects to the Frameo after a reboot and stops the buggy Rockchip RK3326 -# memtrack HAL service. Run this with the USB cable plugged into the Frameo. -# -# The app (OpenPhotoFrame) does this automatically on every boot via su, so -# this script is only needed if the app isn't installed or hasn't launched yet. -# -# Usage: ./scripts/fix-frameo.sh - -set -uo pipefail - -ADB="${HOME}/Library/Android/sdk/platform-tools/adb" -SERIAL="SERIAL_REDACTED" -FRAMEO_IP="192.168.0.0" - -echo "==> Waiting for Frameo USB ADB connection..." -"${ADB}" -s "${SERIAL}" wait-for-device - -echo "==> Stopping buggy memtrack HAL (Rockchip RK3326 firmware bug)..." -"${ADB}" -s "${SERIAL}" shell stop vendor.memtrack-hal-1-0 -STATUS=$("${ADB}" -s "${SERIAL}" shell getprop init.svc.vendor.memtrack-hal-1-0) -echo " vendor.memtrack-hal-1-0: ${STATUS}" - -echo "==> Removing unwanted system apps (idempotent — safe to re-run)..." -# com.adups.fota has android:persistent="true" so pm disable is ignored — use uninstall instead. -# net.frameo.frame ships in /system/priv-app/Frameo.apk; pm uninstall only strips the data-layer -# overlay and falls back to the ROM copy, which then self-updates and reboots the device. -# pm disable-user fully disables the package for user 0, blocking both the ROM copy and any update. -disable_pkg() { - local result - result=$("${ADB}" -s "${SERIAL}" shell pm disable-user --user 0 "$1" 2>&1) - case "${result}" in - *disabled*) echo " $1: disabled" ;; - *) echo " $1: ${result}" ;; - esac - # Also uninstall the data-layer overlay if present - "${ADB}" -s "${SERIAL}" shell pm uninstall -k --user 0 "$1" >/dev/null 2>&1 || true -} -disable_pkg com.adups.fota # OTA updater, android:persistent="true" -disable_pkg android.rockchip.update.service # Rockchip OTA -disable_pkg net.frameo.frame # Frameo photo app — has ROM copy that self-updates and reboots -disable_pkg com.cghs.stresstest # Rockchip factory stress test, android:persistent="true", triggers reboots -disable_pkg com.DeviceTest # Rockchip factory device test -"${ADB}" -s "${SERIAL}" shell am force-stop com.adups.fota 2>/dev/null || true -"${ADB}" -s "${SERIAL}" shell am force-stop android.rockchip.update.service 2>/dev/null || true -"${ADB}" -s "${SERIAL}" shell am force-stop net.frameo.frame 2>/dev/null || true - -echo "==> Enabling WiFi ADB on port 5555..." -"${ADB}" -s "${SERIAL}" tcpip 5555 -sleep 2 - -echo "==> Connecting via WiFi..." -"${ADB}" connect "${FRAMEO_IP}:5555" -echo "" -echo "Done. You can unplug the USB cable." -echo "WiFi ADB: ${ADB} -s ${FRAMEO_IP}:5555 shell" diff --git a/scripts/px110.sh b/scripts/px110.sh new file mode 100755 index 0000000..9fb84c0 --- /dev/null +++ b/scripts/px110.sh @@ -0,0 +1,194 @@ +#!/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 +# +# Override the defaults with env vars, e.g. FRAME_IP=192.168.0.0 ./scripts/px110.sh +# +# See FRAMEO.md for why each step exists. + +set -uo pipefail + +ADB="${ADB:-$HOME/Library/Android/sdk/platform-tools/adb}" +SERIAL="${FRAME_SERIAL:-FRAME_SERIAL_REDACTED}" +IP="${FRAME_IP:-192.168.0.0}" +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 + if "${ADB}" connect "${IP}:${PORT}" 2>&1 | grep -q connected; 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 + if ! unzip -l "${APK}" | grep -q 'lib/armeabi-v7a'; then + bad "APK has no armeabi-v7a slice — this frame is 32-bit only and cannot run it" + exit 1 + fi + ok "armeabi-v7a slice present" + if "${ADB}" -s "${DEV}" install -r -g "${APK}" 2>&1 | grep -q Success; then + ok "installed" + sh_ "monkey -p ${PKG} -c android.intent.category.LAUNCHER 1" >/dev/null + 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}" From ad82ff5a5277f0299e39ce59bd78903b83e9bb68 Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Sat, 19 Sep 2026 18:17:49 +1000 Subject: [PATCH 5/7] Remove the Rockchip-only workarounds Both existed for the retired Frameo 106K (RK3326) and are dead code on the Pexar PX-110: - stopCrashingMemtrackService() stopped a buggy RK3326 memtrack HAL. It was gated on Build.HARDWARE containing "rk" and on a vendor .so that does not exist here, so it never ran - warmEmmcFileCache() preloaded the 106K's failing system files into the page cache. Off by default and pointless on healthy storage. Removes the setting from the config interface, JSON store, runtime-settings sync, the Flutter settings screen and the web UI, plus EmmcCacheWarmService eMMC *monitoring* stays: opf_emmc_io_errors_total and _fetchEmmcIoErrors() are device-independent, and are what ruled out hardware failure during the 2026-09-19 outage. Also fixes two bugs in scripts/px110.sh found by running it: - `cmd | grep -q` under `set -o pipefail` reports failure even on a match, because grep -q exits early and SIGPIPEs the writer. This made the APK armeabi-v7a guard reject a valid APK. Capture output, then match - After --install the app relaunches and re-runs enableWifiAdb(), which does `stop adbd; start adbd` and kills the script's own connection, so every health check read as an empty failure. Wait and reconnect first --- .../openphotoframe/ScreenControlHandler.kt | 57 ------------------- lib/domain/interfaces/config_provider.dart | 3 - .../android_runtime_settings_sync.dart | 9 --- .../services/emmc_cache_warm_service.dart | 12 ---- .../services/json_config_service.dart | 8 --- .../services/web_server_service.dart | 8 --- lib/ui/screens/settings_screen.dart | 19 ------- scripts/px110.sh | 22 ++++++- 8 files changed, 19 insertions(+), 119 deletions(-) delete mode 100644 lib/infrastructure/services/emmc_cache_warm_service.dart 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 39e276c..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 @@ -47,13 +47,8 @@ class ScreenControlHandler(private val context: Context) { } fun configureChannel(flutterEngine: FlutterEngine) { - // The Rockchip RK3326 memtrack HAL has a bug (readdir on null DIR*) that causes - // it to crash in a loop and eventually destabilise the system. Stop it once on - // startup — it only feeds diagnostic tools and is not needed for normal operation. - stopCrashingMemtrackService() val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) if (prefs.getBoolean("flutter.wifi_adb_enabled", true)) enableWifiAdb() - if (prefs.getBoolean("flutter.emmc_cache_warm_enabled", false)) warmEmmcFileCache() MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> when (call.method) { @@ -336,32 +331,6 @@ class ScreenControlHandler(private val context: Context) { return result } - private fun warmEmmcFileCache() { - // These files sit on eMMC sectors that have shown intermittent I/O errors. - // Reading them at startup populates the page cache so the kernel does not need - // to fetch them from the eMMC when a running daemon first touches a cold page. - val files = listOf( - "/system/apex/com.android.os.statsd/bin/statsd", - "/system/bin/audioserver", - "/system/bin/netd", - "/system/bin/vdc", - "/system/bin/wificond", - "/system/etc/task_profiles.json", - "/system/lib/libandroid_runtime.so" - ) - for (path in files) { - try { - java.io.File(path).inputStream().use { stream -> - val buf = ByteArray(65536) - while (stream.read(buf) != -1) { /* discard — just pulling pages into cache */ } - } - Log.i(TAG, "eMMC cache warm: $path") - } catch (e: Exception) { - Log.w(TAG, "eMMC cache warm failed for $path: ${e.message}") - } - } - } - private fun enableWifiAdb() { try { Runtime.getRuntime().exec(arrayOf("/system/xbin/su", "0", "/system/bin/setprop", "service.adb.tcp.port", "5555")).waitFor() @@ -373,32 +342,6 @@ class ScreenControlHandler(private val context: Context) { } } - private fun stopCrashingMemtrackService() { - // Only applies to Rockchip RK3326 devices — the memtrack HAL on this SoC has a - // bug (readdir on null DIR*) that causes it to crash in a loop and destabilise - // the system. It only feeds diagnostic tools so stopping it is safe. - // - // The device has SELinux permissive, no PR_SET_NO_NEW_PRIVS, and a setuid-root - // /system/xbin/su binary — so the app process can escalate to root via su. - val isRockchip = Build.HARDWARE.contains("rk", ignoreCase = true) || - java.io.File("/vendor/lib/hw/memtrack.rk3326.so").exists() - if (!isRockchip) return - try { - val proc = Runtime.getRuntime().exec( - arrayOf("/system/xbin/su", "0", "/system/bin/stop", "vendor.memtrack-hal-1-0") - ) - val exit = proc.waitFor() - if (exit == 0) { - Log.i(TAG, "Stopped vendor.memtrack-hal-1-0 via su (buggy Rockchip RK3326 HAL)") - } else { - Log.w(TAG, "su stop returned exit code $exit, falling back to direct stop") - Runtime.getRuntime().exec("stop vendor.memtrack-hal-1-0") - } - } catch (e: Exception) { - Log.d(TAG, "Could not stop memtrack service: ${e.message}") - } - } - /** * Wake up the screen immediately. */ diff --git a/lib/domain/interfaces/config_provider.dart b/lib/domain/interfaces/config_provider.dart index ba6be49..2def216 100644 --- a/lib/domain/interfaces/config_provider.dart +++ b/lib/domain/interfaces/config_provider.dart @@ -43,9 +43,6 @@ abstract class ConfigProvider extends ChangeNotifier { bool get wifiAdbEnabled; // Enable WiFi ADB on port 5555 via su on every boot (Android only) set wifiAdbEnabled(bool value); - bool get emmcCacheWarmEnabled; // Preload flagged system files into page cache on boot (device-specific) - set emmcCacheWarmEnabled(bool 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/infrastructure/services/android_runtime_settings_sync.dart b/lib/infrastructure/services/android_runtime_settings_sync.dart index 6de2139..a673e2b 100644 --- a/lib/infrastructure/services/android_runtime_settings_sync.dart +++ b/lib/infrastructure/services/android_runtime_settings_sync.dart @@ -1,6 +1,5 @@ import '../../domain/interfaces/config_provider.dart'; import 'autostart_service.dart'; -import 'emmc_cache_warm_service.dart'; import 'keep_alive_service.dart'; import 'wifi_adb_service.dart'; @@ -10,8 +9,6 @@ abstract class AndroidRuntimeSettingsWriter { Future setKeepAliveEnabled(bool enabled); Future setWifiAdbEnabled(bool enabled); - - Future setEmmcCacheWarmEnabled(bool enabled); } class SharedPreferencesAndroidRuntimeSettingsWriter @@ -30,11 +27,6 @@ class SharedPreferencesAndroidRuntimeSettingsWriter Future setWifiAdbEnabled(bool enabled) { return WifiAdbService.setEnabled(enabled); } - - @override - Future setEmmcCacheWarmEnabled(bool enabled) { - return EmmcCacheWarmService.setEnabled(enabled); - } } class AndroidRuntimeSettingsSync { @@ -47,6 +39,5 @@ class AndroidRuntimeSettingsSync { await _writer.setAutostartEnabled(configProvider.autostartOnBoot); await _writer.setKeepAliveEnabled(configProvider.keepAliveEnabled); await _writer.setWifiAdbEnabled(configProvider.wifiAdbEnabled); - await _writer.setEmmcCacheWarmEnabled(configProvider.emmcCacheWarmEnabled); } } \ No newline at end of file diff --git a/lib/infrastructure/services/emmc_cache_warm_service.dart b/lib/infrastructure/services/emmc_cache_warm_service.dart deleted file mode 100644 index 6b1d942..0000000 --- a/lib/infrastructure/services/emmc_cache_warm_service.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'dart:io'; -import 'package:shared_preferences/shared_preferences.dart'; - -class EmmcCacheWarmService { - static const String _key = 'emmc_cache_warm_enabled'; - - static Future setEnabled(bool enabled) async { - if (!Platform.isAndroid) return; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_key, enabled); - } -} diff --git a/lib/infrastructure/services/json_config_service.dart b/lib/infrastructure/services/json_config_service.dart index 65393b5..b859b05 100644 --- a/lib/infrastructure/services/json_config_service.dart +++ b/lib/infrastructure/services/json_config_service.dart @@ -330,14 +330,6 @@ class JsonConfigService extends ConfigProvider { _config['wifi_adb_enabled'] = value; } - @override - bool get emmcCacheWarmEnabled => _config['emmc_cache_warm_enabled'] ?? false; - - @override - set emmcCacheWarmEnabled(bool value) { - _config['emmc_cache_warm_enabled'] = value; - } - // Auto-update settings @override bool get autoUpdateEnabled => _config['auto_update_enabled'] ?? false; diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart index 60454e5..52a82c1 100644 --- a/lib/infrastructure/services/web_server_service.dart +++ b/lib/infrastructure/services/web_server_service.dart @@ -165,7 +165,6 @@ class WebServerService { 'autostart_on_boot': _config.autostartOnBoot, 'keep_alive_enabled': _config.keepAliveEnabled, 'wifi_adb_enabled': _config.wifiAdbEnabled, - 'emmc_cache_warm_enabled': _config.emmcCacheWarmEnabled, 'auto_update_enabled': _config.autoUpdateEnabled, }; @@ -242,7 +241,6 @@ class WebServerService { setBool('autostart_on_boot', (v) => _config.autostartOnBoot = v); setBool('keep_alive_enabled', (v) => _config.keepAliveEnabled = v); setBool('wifi_adb_enabled', (v) => _config.wifiAdbEnabled = v); - setBool('emmc_cache_warm_enabled', (v) => _config.emmcCacheWarmEnabled = v); setBool('auto_update_enabled', (v) => _config.autoUpdateEnabled = v); await _config.save(); @@ -815,10 +813,6 @@ button{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-size: WiFi ADBEnable ADB over WiFi on port 5555 on every boot (requires root)
-
- Warm system file cache on bootPreload key system files into RAM after boot to reduce crash risk from marginal eMMC sectors (device-specific) - -
Automatic updatesCheck GitHub for new versions @@ -934,7 +928,6 @@ function applyConfig(c){ setCheck('autostart', c.autostart_on_boot??false); setCheck('keep-alive', c.keep_alive_enabled??false); setCheck('wifi-adb', c.wifi_adb_enabled??true); - setCheck('emmc-cache-warm', c.emmc_cache_warm_enabled??false); setCheck('auto-update', c.auto_update_enabled??false); } @@ -1023,7 +1016,6 @@ async function saveSettings(){ autostart_on_boot:document.getElementById('autostart').checked, keep_alive_enabled:document.getElementById('keep-alive').checked, wifi_adb_enabled:document.getElementById('wifi-adb').checked, - emmc_cache_warm_enabled:document.getElementById('emmc-cache-warm').checked, auto_update_enabled:document.getElementById('auto-update').checked, }; try{ diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 1155094..5298293 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -20,7 +20,6 @@ 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'; -import '../../infrastructure/services/emmc_cache_warm_service.dart'; import '../../infrastructure/services/native_screen_control_service.dart'; import '../../infrastructure/services/keep_alive_service.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -61,7 +60,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse late bool _deleteOrphanedFiles; late bool _autostartOnBoot; late bool _keepAliveEnabled; - late bool _emmcCacheWarmEnabled; late bool _autoUpdateEnabled; late bool _autoUpdateSilent; bool _isDeviceOwner = false; @@ -140,7 +138,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse _deleteOrphanedFiles = config.deleteOrphanedFiles; _autostartOnBoot = config.autostartOnBoot; _keepAliveEnabled = config.keepAliveEnabled; - _emmcCacheWarmEnabled = config.emmcCacheWarmEnabled; _autoUpdateEnabled = config.autoUpdateEnabled; _autoUpdateSilent = config.autoUpdateSilent; if (Platform.isAndroid) { @@ -334,7 +331,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse config.deleteOrphanedFiles = _deleteOrphanedFiles; config.autostartOnBoot = _autostartOnBoot; config.keepAliveEnabled = _keepAliveEnabled; - config.emmcCacheWarmEnabled = _emmcCacheWarmEnabled; config.autoUpdateEnabled = _autoUpdateEnabled; config.autoUpdateSilent = _autoUpdateSilent; config.showClock = _showClock; @@ -367,8 +363,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Sync keep alive setting to SharedPreferences for WakeReceiver await KeepAliveService.setEnabled(_keepAliveEnabled); - // Sync eMMC cache warm setting to SharedPreferences for ScreenControlHandler - await EmmcCacheWarmService.setEnabled(_emmcCacheWarmEnabled); if (_syncType == 'nextcloud_link') { config.setSourceConfig('nextcloud_link', newWebDavSourceConfig.toMap()); @@ -784,19 +778,6 @@ class _SettingsScreenState extends State with WidgetsBindingObse const SizedBox(height: 8), - SwitchListTile( - title: const Text('Warm system file cache on boot'), - subtitle: const Text( - 'Reads key system files into RAM after boot to reduce crash risk from marginal eMMC sectors. Enable only on affected devices.', - ), - secondary: const Icon(Icons.memory), - value: _emmcCacheWarmEnabled, - onChanged: (value) { - setState(() => _emmcCacheWarmEnabled = value); - }, - ), - - const SizedBox(height: 8), _buildAutoUpdateSection(), const SizedBox(height: 24), diff --git a/scripts/px110.sh b/scripts/px110.sh index 9fb84c0..2c90709 100755 --- a/scripts/px110.sh +++ b/scripts/px110.sh @@ -70,7 +70,8 @@ elif "${ADB}" -s "${SERIAL}" shell true >/dev/null 2>&1; then warn "Wi-Fi unavailable; using USB. Re-enabling TCP mode..." "${ADB}" -s "${SERIAL}" tcpip "${PORT}" >/dev/null 2>&1 sleep 4 - if "${ADB}" connect "${IP}:${PORT}" 2>&1 | grep -q connected; then + 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 @@ -134,14 +135,29 @@ if [ "${DO_INSTALL}" -eq 1 ]; then bad "Not found. Build first: flutter build apk --release" exit 1 fi - if ! unzip -l "${APK}" | grep -q 'lib/armeabi-v7a'; then + 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" - if "${ADB}" -s "${DEV}" install -r -g "${APK}" 2>&1 | grep -q Success; then + 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 From 559df1760c7b90d0b91242dac5916b6b827d793c Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Mon, 21 Sep 2026 09:48:58 +1000 Subject: [PATCH 6/7] Add an optional password to the web settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings server binds 0.0.0.0:8080 with Access-Control-Allow-Origin: * and had no authentication, so any host on the LAN could GET /api/config or /api/log. Both disclose the iCloud shared-album token, which is enough to open the album — and the wildcard CORS header meant any page a LAN user visited could read it too. - New web_ui_password config setting; empty preserves the old open behaviour - HTTP Basic auth on every route, checked with a constant-time comparison. Any username is accepted; only the password is verified - 401 responses carry WWW-Authenticate, so browsers prompt normally - Settable from the web UI and from the on-device settings screen. The on-device field is the escape hatch if the password is forgotten /metrics is deliberately exempt so Prometheus keeps scraping without credentials. It exposes only counters and gauges — no album URLs, tokens or log lines. Protecting it too means adding basic_auth to the scrape config. Verified on the frame: unauthenticated /api/config, /api/log, /api/status and / return 401 while /metrics returns 200; a wrong password returns 401; the correct one returns 200; and the album token is no longer retrievable without credentials. With no password set, all routes behave as before. --- FRAMEO.md | 30 +++++++++ lib/domain/interfaces/config_provider.dart | 3 + .../services/json_config_service.dart | 8 +++ .../services/web_server_service.dart | 65 +++++++++++++++++++ lib/ui/screens/settings_screen.dart | 26 ++++++++ 5 files changed, 132 insertions(+) diff --git a/FRAMEO.md b/FRAMEO.md index 05b294b..5b13d11 100644 --- a/FRAMEO.md +++ b/FRAMEO.md @@ -191,6 +191,36 @@ 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://192.168.0.0: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 diff --git a/lib/domain/interfaces/config_provider.dart b/lib/domain/interfaces/config_provider.dart index 2def216..0077555 100644 --- a/lib/domain/interfaces/config_provider.dart +++ b/lib/domain/interfaces/config_provider.dart @@ -43,6 +43,9 @@ abstract class ConfigProvider extends ChangeNotifier { 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/infrastructure/services/json_config_service.dart b/lib/infrastructure/services/json_config_service.dart index b859b05..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'] ?? ''; diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart index 52a82c1..1b61f63 100644 --- a/lib/infrastructure/services/web_server_service.dart +++ b/lib/infrastructure/services/web_server_service.dart @@ -102,6 +102,15 @@ class WebServerService { 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') { @@ -123,6 +132,54 @@ class WebServerService { } } + // --------------------------------------------------------------------------- + // 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 // --------------------------------------------------------------------------- @@ -165,6 +222,7 @@ class WebServerService { '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, }; @@ -241,6 +299,7 @@ class WebServerService { 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(); @@ -817,6 +876,10 @@ button{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-size: Automatic updatesCheck GitHub for new versions
+
+ + +
@@ -927,6 +990,7 @@ function applyConfig(c){ // Android setCheck('autostart', c.autostart_on_boot??false); setCheck('keep-alive', c.keep_alive_enabled??false); + document.getElementById('web-ui-password').value=c.web_ui_password??''; setCheck('wifi-adb', c.wifi_adb_enabled??true); setCheck('auto-update', c.auto_update_enabled??false); } @@ -1016,6 +1080,7 @@ async function saveSettings(){ autostart_on_boot:document.getElementById('autostart').checked, keep_alive_enabled:document.getElementById('keep-alive').checked, wifi_adb_enabled:document.getElementById('wifi-adb').checked, + web_ui_password:document.getElementById('web-ui-password').value, auto_update_enabled:document.getElementById('auto-update').checked, }; try{ diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 5298293..f578465 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -55,6 +55,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse late TextEditingController _webdavPasswordController; late bool _webdavAllowInvalidCertificate; late TextEditingController _icloudAlbumUrlController; + late TextEditingController _webUiPasswordController; late int _syncIntervalMinutes; late int _syncTimeoutSeconds; late bool _deleteOrphanedFiles; @@ -136,6 +137,8 @@ class _SettingsScreenState extends State with WidgetsBindingObse _syncIntervalMinutes = config.syncIntervalMinutes; _syncTimeoutSeconds = config.syncTimeoutSeconds; _deleteOrphanedFiles = config.deleteOrphanedFiles; + _webUiPasswordController = + TextEditingController(text: config.webUiPassword); _autostartOnBoot = config.autostartOnBoot; _keepAliveEnabled = config.keepAliveEnabled; _autoUpdateEnabled = config.autoUpdateEnabled; @@ -291,6 +294,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse _webdavUserController.dispose(); _webdavPasswordController.dispose(); _icloudAlbumUrlController.dispose(); + _webUiPasswordController.dispose(); super.dispose(); } @@ -329,6 +333,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse config.syncIntervalMinutes = _syncIntervalMinutes; config.syncTimeoutSeconds = _syncTimeoutSeconds; config.deleteOrphanedFiles = _deleteOrphanedFiles; + config.webUiPassword = _webUiPasswordController.text.trim(); config.autostartOnBoot = _autostartOnBoot; config.keepAliveEnabled = _keepAliveEnabled; config.autoUpdateEnabled = _autoUpdateEnabled; @@ -778,6 +783,27 @@ 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), From e7a65e6a8f9b808f21e59720e15b226d685ca48c Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Mon, 21 Sep 2026 09:55:07 +1000 Subject: [PATCH 7/7] Keep the device serial and LAN address out of the repository This is a public fork, so the frame's serial and IP should not be in it. - scripts/px110.sh now reads FRAME_SERIAL and FRAME_IP from scripts/frame.env, which is gitignored, and exits with guidance if they are unset. Environment variables still win over the file - Add scripts/frame.env.example as the template to copy - Replace the literal serial and IP in FRAMEO.md with $FRAME_SERIAL / $FRAME_IP, and drop the retired frame's serial from FRAME_SETUP.md --- .gitignore | 3 +++ FRAMEO.md | 18 +++++++++--------- FRAME_SETUP.md | 2 +- scripts/frame.env.example | 8 ++++++++ scripts/px110.sh | 21 ++++++++++++++++++--- 5 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 scripts/frame.env.example diff --git a/.gitignore b/.gitignore index e6adb2b..9135cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ app.*.map.json # 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 index 5b13d11..9b06e3e 100644 --- a/FRAMEO.md +++ b/FRAMEO.md @@ -12,8 +12,8 @@ failure and went back to Amazon; what still transfers is kept at the | 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 | `FRAME_SERIAL_REDACTED` | -| IP | 192.168.0.0 (was .141 — it moves on DHCP, check before assuming) | +| 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: @@ -203,7 +203,7 @@ 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://192.168.0.0:8080/api/config +curl -u admin:PASSWORD http://$FRAME_IP:8080/api/config ``` `/metrics` is deliberately **left open** so Prometheus keeps scraping without @@ -233,21 +233,21 @@ re-runs `setprop service.adb.tcp.port 5555` and restarts `adbd` at boot. A cold reaches photos in about 30 seconds with Wi-Fi ADB already back. Manual setup: ```sh -adb -s FRAME_SERIAL_REDACTED tcpip 5555 -adb connect 192.168.0.0:5555 +adb -s "$FRAME_SERIAL" tcpip 5555 +adb connect "$FRAME_IP:5555" ``` ### ADB quick reference ```sh # Wi-Fi (preferred — USB is unreliable) -adb -s 192.168.0.0:5555 shell +adb -s "$FRAME_IP:5555" shell # Deploy (needs the install lock cleared first) -adb -s 192.168.0.0:5555 install -r -g build/app/outputs/flutter-apk/app-release.apk +adb -s "$FRAME_IP:5555" install -r -g build/app/outputs/flutter-apk/app-release.apk # Metrics without adb -curl -s http://192.168.0.0:8080/metrics | grep ^opf_uptime_seconds +curl -s http://$FRAME_IP:8080/metrics | grep ^opf_uptime_seconds ``` ### The 2026-09-19 respawn-loop outage @@ -299,7 +299,7 @@ Diagnostic notes for next time: ## Previous device: Frameo 106K (retired) -Rockchip RK3326, Android 11, USB serial `SERIAL_REDACTED`. Returned to Amazon after +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 diff --git a/FRAME_SETUP.md b/FRAME_SETUP.md index 94bccb6..2e8dd9b 100644 --- a/FRAME_SETUP.md +++ b/FRAME_SETUP.md @@ -6,7 +6,7 @@ adb devices -l ``` -Note the device serial (e.g. `SERIAL_REDACTED`). Use `-s ` in all commands below if multiple devices are connected. +Note the device serial reported by `adb devices`. Use `-s ` in all commands below if multiple devices are connected. ## 2. Install the APK 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 index 2c90709..8685810 100755 --- a/scripts/px110.sh +++ b/scripts/px110.sh @@ -10,15 +10,30 @@ # ./scripts/px110.sh --health # report only, change nothing # ./scripts/px110.sh --install # also install the release APK # -# Override the defaults with env vars, e.g. FRAME_IP=192.168.0.0 ./scripts/px110.sh +# 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:-FRAME_SERIAL_REDACTED}" -IP="${FRAME_IP:-192.168.0.0}" +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"