From 63dcfa59cd477fbbee08a43e8b84e10aa7d2279b Mon Sep 17 00:00:00 2001 From: PepoTech <183022507+pepitolas13@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:07:48 +0200 Subject: [PATCH] Send new photos from iPhone with the app off screen On iPhone photos only ever reached the PC while PepoConnect was in front. iOS suspends the process on the way out, and there is no API that wakes a suspended app when a photo is taken: PHPhotoLibraryChangeObserver only delivers to a running process, the BGTaskScheduler tasks run minutes to hours later, and BGContinuedProcessingTask needs a foreground tap. The only way a photo leaves on its own is for the process never to be suspended. PepoBackground.swift keeps it alive with the audio background mode: one second of silence on a loop, session .playback + .mixWithOthers, no now-playing info. Nothing is audible, other apps keep their audio and no indicator shows up, but the Dart engine keeps running exactly as it does in the foreground. A watchdog and the interruption, route-change and media-reset notifications win the session back when a call or another app takes it. Transfers are wrapped in a beginBackgroundTask assertion, and BGAppRefresh/BGProcessing are registered as the safety net for when the engine is not running at all. None of those Info.plist keys needs an entitlement, so they survive re-signing with a free Apple ID. Staying alive is not enough on its own, because automatic sending had three holes of its own: - MediaServer sent only to whoever happened to be connected at that instant, so a photo taken with the PC asleep was dropped and never retried. - engine.setAutoSend was called only from the settings switch, so after any restart the switch read on while nothing was ever sent. - MediaSource takes a fresh snapshot when it starts, so whatever was photographed while the process was dead was never reported as new. AutoSendQueue now writes every pending item to disk, hands it over as soon as the device is reachable, and remembers what already went. AppServices re-applies the setting at every start, and a catch-up pass picks up what appeared while nothing was listening. Caps on age, backlog size and retries keep a phone that was offline for a week from flooding the PC. HEIC conversion is finally wired in, and the copies the platform exports to send an original are cleaned up once nothing is queued or in flight. Bonjour/mDNS discovery goes with it: the UDP beacon cannot run on iPhone (broadcast needs an entitlement Apple does not hand out), so without it a PC that reboots onto a new DHCP lease disappeared for good. Both ends announce now. The Flatpak manifest needed the Avahi name on the system bus, not the session one. Settings tell the truth on iPhone now, including a background-state row that shows how long the process has been alive, and a warning when photo access is limited to a hand-picked selection, which hides every later photo. ci.yml grows an iOS job: the Swift is only ever compiled on a macOS runner, and it also checks the background keys survive into the built bundle. --- .github/workflows/ci.yml | 39 ++ .github/workflows/release.yml | 3 + README.en.md | 7 +- README.md | 22 +- docs/packaging.md | 20 +- integration_test/e2e_test.dart | 35 ++ integration_test/support.dart | 2 + ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 70 +++- ios/Runner/Info.plist | 7 + ios/Runner/PepoBackground.swift | 359 +++++++++++++++++ lib/app/app_services.dart | 92 ++++- lib/app/bootstrap.dart | 7 + lib/features/settings/permissions.dart | 18 +- .../settings/sections/phone_section.dart | 66 ++- lib/features/settings/settings_providers.dart | 5 - lib/l10n/app_en.arb | 9 + lib/l10n/app_es.arb | 16 + lib/l10n/generated/app_localizations.dart | 54 +++ lib/l10n/generated/app_localizations_en.dart | 32 ++ lib/l10n/generated/app_localizations_es.dart | 32 ++ lib/main.dart | 13 +- lib/platform/bonjour_discovery.dart | 276 +++++++++++++ lib/platform/ios_background.dart | 89 +++++ lib/platform/media_source_photo_manager.dart | 47 ++- lib/platform/mobile_permissions.dart | 13 +- lib/platform/pepo_native.dart | 126 +++++- lib/shared/util/format.dart | 10 + lib/state/stores.dart | 25 ++ packages/pepo_core/lib/pepo_core.dart | 1 + .../pepo_core/lib/src/engine/pepo_engine.dart | 28 +- .../lib/src/media/auto_send_queue.dart | 378 ++++++++++++++++++ .../pepo_core/lib/src/media/media_server.dart | 95 ++++- .../pepo_core/test/auto_send_queue_test.dart | 298 ++++++++++++++ packages/pepo_core/test/media_test.dart | 119 ++++++ packaging/linux/make-flatpak.sh | 3 +- .../features/settings/phone_section_test.dart | 151 +++++++ test/platform/bonjour_discovery_test.dart | 104 +++++ test/platform/ios_background_test.dart | 132 ++++++ 39 files changed, 2706 insertions(+), 101 deletions(-) create mode 100644 ios/Runner/PepoBackground.swift create mode 100644 lib/platform/bonjour_discovery.dart create mode 100644 lib/platform/ios_background.dart create mode 100644 packages/pepo_core/lib/src/media/auto_send_queue.dart create mode 100644 packages/pepo_core/test/auto_send_queue_test.dart create mode 100644 test/features/settings/phone_section_test.dart create mode 100644 test/platform/bonjour_discovery_test.dart create mode 100644 test/platform/ios_background_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c694bd0..7e7c544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,11 @@ on: branches: [main] pull_request: workflow_call: + inputs: + ios: + type: boolean + default: true + description: "Compile the iOS app (macOS runner)" concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -41,3 +46,37 @@ jobs: dart pub get dart analyze --fatal-infos dart test --exclude-tags perf + + # The Swift side (the background keep-alive engine) is only ever compiled + # here: there is no macOS in the development setup, so without this a build + # error would not show up until a release tag. + ios: + if: ${{ inputs.ios == null || inputs.ios }} + runs-on: macos-15 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.47.4" + cache: true + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + - uses: Swatinem/rust-cache@v2 + with: { workspaces: packages/pepo_native/rust } + - name: Newest Xcode on the runner (plugins use the iOS 18.4+ SDK) + run: | + sudo xcode-select -s "$(ls -d /Applications/Xcode_*.app | sort -V | tail -n1)" + xcodebuild -version + - run: flutter pub get + - run: flutter build ios --release --no-codesign + - name: The background keys have to survive into the bundle + run: | + set -eu + plist=build/ios/iphoneos/Runner.app/Info.plist + read_key() { /usr/libexec/PlistBuddy -c "Print :$1" "$plist"; } + read_key UIBackgroundModes | grep -q audio + read_key BGTaskSchedulerPermittedIdentifiers | grep -q org.pepoconnect.app.sync + read_key NSBonjourServices | grep -q _pepoconnect._tcp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a3cea..18f97ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ env: jobs: check: uses: ./.github/workflows/ci.yml + # This workflow builds iOS itself right below. + with: + ios: false windows: if: github.event_name == 'push' || inputs.windows diff --git a/README.en.md b/README.en.md index 8a53a6e..fcddb5e 100644 --- a/README.en.md +++ b/README.en.md @@ -38,7 +38,12 @@ sideloading and is not an App Store download. ## What you can do - Browse your phone's gallery from your computer and download original files. -- See newly taken photos while the devices are connected. +- See newly taken photos as they happen, with the app closed. On Android a + foreground service keeps the engine alive; on iPhone the system audio mode + does (an inaudible silent loop that leaves your music alone). Whatever cannot + be sent right away — computer asleep, Wi-Fi gone — is kept and sent on + reconnect. Closing the app from the iPhone app switcher stops the engine + until you open it again. - Send files in both directions, resume interrupted transfers and connect several devices at once. - Share clipboard text. Android background clipboard restrictions still apply; diff --git a/README.md b/README.md index d3ee5b7..a111a3e 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,16 @@ Capturas de la aplicación real. dispositivo (`Fotos/`, `Vídeos/`, `Archivos/`) o unificado desde Ajustes. - **Emparejamiento por QR** (móvil) o por código de 6 dígitos (PC↔PC), con reconexión automática. -- **Sigue conectado con la app cerrada** (Android): con «Servicio en segundo - plano» activado, un servicio en primer plano mantiene vivo el motor aunque - cierres la ventana o la quites de recientes, y lo rearranca solo si Android - mata el proceso o reinicias el móvil. +- **Sigue conectado con la app cerrada**: con «Servicio en segundo plano» + activado, haces una foto y llega al PC sin abrir PepoConnect. En Android, un + servicio en primer plano mantiene vivo el motor aunque cierres la ventana o + la quites de recientes, y lo rearranca solo si Android mata el proceso o + reinicias el móvil. En iPhone, PepoConnect se mantiene en marcha con el modo + de audio del sistema (un bucle de silencio inaudible que no interrumpe tu + música); si cierras la app desde el selector de apps o reinicias el iPhone, + las fotos pendientes esperan en cola y salen al volver a abrirla. Lo que no + se puede enviar en el momento —el PC apagado, la Wi-Fi caída— queda guardado + y se envía solo al reconectar. - **Portapapeles compartido**: un solo interruptor por pareja de dispositivos (se activa en cualquiera de los dos y vale para ambos sentidos). El PC lo envía al instante; Android no deja leer el portapapeles en segundo plano, @@ -177,8 +183,12 @@ Segunda instancia en el mismo PC para probar: `flutter run -d windows --dart-ent 2. Instálalo con Sideloadly (Windows) o AltStore con tu Apple ID. 3. Con Apple ID gratuito la app caduca cada 7 días (AltServer la renueva sola si está en la misma Wi-Fi) y puedes tener 3 apps así a la vez. -4. La primera vez, acepta el permiso de "Red local"; en iPhone las fotos - llegan al PC mientras PepoConnect está en pantalla. +4. La primera vez, acepta el permiso de "Red local" y da acceso **completo** a + las fotos: con «Acceso limitado» iOS oculta las fotos nuevas y el envío + automático se queda sin nada que enviar (Ajustes › Este móvil avisa si pasa). +5. Para que las fotos salgan con la app cerrada, deja activado «Servicio en + segundo plano» en Ajustes › Este móvil. «Estado del segundo plano» dice si + el motor está vivo y desde cuándo. ## Estructura diff --git a/docs/packaging.md b/docs/packaging.md index af44731..fa1bc7b 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -213,8 +213,20 @@ vacío al lado. El proyecto usa Swift Package Manager para los plugins (no hay `Podfile`). - `ios/Runner/Info.plist`: `CFBundleDisplayName` PepoConnect, textos de uso de fototeca (leer y guardar), cámara y red local, `NSBonjourServices` (`_pepoconnect._tcp`), `UIBackgroundModes` - (`processing`), `UIFileSharingEnabled`, `LSSupportsOpeningDocumentsInPlace`, - `ITSAppUsesNonExemptEncryption=false` y el esquema de URL `pepoconnect`. + (`audio`, `fetch`, `processing`), `BGTaskSchedulerPermittedIdentifiers` + (`org.pepoconnect.app.refresh`, `org.pepoconnect.app.sync`), `UIFileSharingEnabled`, + `LSSupportsOpeningDocumentsInPlace`, `ITSAppUsesNonExemptEncryption=false` y el esquema de URL + `pepoconnect`. Ninguna de esas claves necesita entitlements, así que sobreviven a la refirma de + AltStore o Sideloadly con un Apple ID gratuito. El job `ios` de `ci.yml` comprueba que siguen en + el bundle compilado. +- Segundo plano (`ios/Runner/PepoBackground.swift`): iOS no despierta una app suspendida al hacer + una foto —`PHPhotoLibraryChangeObserver` solo entrega a un proceso vivo y las `BGTask` son + oportunistas—, así que `KeepAlive` reproduce un bucle de silencio con la sesión de audio en + `.playback` + `.mixWithOthers` para que el proceso no llegue a suspenderse. `BackgroundHold` + envuelve las transferencias en un `beginBackgroundTask` y `BackgroundTasks` registra las dos + `BGTask` como red de seguridad. Todo se controla desde Dart por el canal `org.pepoconnect/native` + (`keepAliveStart`, `keepAliveStop`, `backgroundHold`, `backgroundStatus`, `backgroundReady`, + `backgroundTaskDone`, y `backgroundTask` en sentido contrario). - `ios/ExportOptions.plist`: exportación ad-hoc con firma manual; `packaging/ios/sign-and-export.sh` sustituye `TEAM_ID` y `PROFILE_NAME` con los secretos `IOS_TEAM_ID` y el nombre del perfil, y ejecuta `flutter build ipa --export-options-plist=ios/ExportOptions.plist`. @@ -228,6 +240,10 @@ vacío al lado. - `linux/runner/my_application.cc`: título `PepoConnect`, tamaño por defecto 1100x700. - Scripts en `packaging/linux/`: `make-tarball.sh`, `make-appimage.sh`, `make-flatpak.sh`, `bundle-extra-libs.sh` (indicador de bandeja y libnotify), `install-desktop-entry.sh`. +- Descubrimiento mDNS: `bonsoir_linux` habla con Avahi por el bus **de sistema**, así que el + manifiesto Flatpak lleva `--system-talk-name=org.freedesktop.Avahi`. Sin `avahi-daemon` no hay + anuncio ni exploración y queda solo el beacon UDP, que en Linux sigue activo; el iPhone, que no + puede usar UDP, dejaría de encontrar ese equipo. - Iconos: `flutter_launcher_icons` no soporta Linux. Los scripts copian a mano `assets/icon/pepoconnect-256.png` y `assets/icon/pepoconnect-512.png` como `org.pepoconnect.PepoConnect.png`; genera esos dos PNG junto con el resto. diff --git a/integration_test/e2e_test.dart b/integration_test/e2e_test.dart index 5f70187..6afb97a 100644 --- a/integration_test/e2e_test.dart +++ b/integration_test/e2e_test.dart @@ -101,4 +101,39 @@ void main() { await settle(tester); await pumpUntilFound(tester, find.text('Guion PepoTech.txt')); }); + + testWidgets('auto-send: photos leave the phone on their own, offline ones too', (tester) async { + final w = await E2eWorld.start(tester); + await tester.tap(find.text('Añadir móvil')); + await settle(tester); + final pc = await w.pairThroughQr(tester); + await pumpUntilFound(tester, find.text('Emparejado'), timeout: const Duration(seconds: 15)); + await tester.tap(find.text('Empezar')); + await settle(tester); + + // The phone forwards every new photo to this PC, with nobody watching. + await w.phone.setAutoSend(pc.deviceId, true); + + final first = await w.takePhoto('IMG_AUTO_0001.jpg', r: 30, g: 200, b: 90); + final arrived = await waitForFile(tester, w.hubDownloads, 'IMG_AUTO_0001.jpg'); + expect(await arrived.length(), await first.length()); + + // Taken with the PC away: it has to wait in the queue and go out when + // the connection comes back, not be dropped on the floor. + await w.phone.sessions.disconnect(pc.deviceId); + final offline = await w.takePhoto('IMG_AUTO_0002.jpg', r: 200, g: 40, b: 40); + await waitFor(tester, () { + final queue = w.phone.mediaServer?.autoSend; + return queue != null && queue.pendingCount > 0 ? true : null; + }, what: 'the offline photo waiting in the queue'); + + w.phone.reconnectAll(); + final drained = await waitForFile( + tester, + w.hubDownloads, + 'IMG_AUTO_0002.jpg', + timeout: const Duration(seconds: 60), + ); + expect(await drained.length(), await offline.length()); + }); } diff --git a/integration_test/support.dart b/integration_test/support.dart index 64005ec..d95e346 100644 --- a/integration_test/support.dart +++ b/integration_test/support.dart @@ -68,6 +68,8 @@ class E2eWorld { cacheDir: p.join(hubData, 'cache'), ), options: const LaunchOptions(profile: 'e2e', port: hubPort), + // Keep the test off the real network. + bonjour: false, ); final phone = PepoEngine( diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ffbe353..09013ab 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 7B10A4022F1A55B000C636F2 /* PepoBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B10A4012F1A55B000C636F2 /* PepoBackground.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -50,6 +51,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7B10A4012F1A55B000C636F2 /* PepoBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PepoBackground.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -123,6 +125,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 7B10A4012F1A55B000C636F2 /* PepoBackground.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -284,6 +287,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + 7B10A4022F1A55B000C636F2 /* PepoBackground.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e46e543..cda4e6c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,10 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // iOS refuses identifiers registered after launch finishes, so this has + // to happen before anything else asks for time. + PepoTasks.shared.register() + PepoTasks.shared.schedule() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -21,9 +25,10 @@ import UIKit } } -/// The `org.pepoconnect/native` channel on iOS. Only `playWav` exists here: -/// it plays a short 16-bit mono WAV (the transfer chime). Everything else -/// answers "not implemented", which the Dart side treats as a no-op. +/// The `org.pepoconnect/native` channel on iOS: the transfer chime and the +/// background engine ([KeepAlive], [BackgroundHold], [PepoTasks]). +/// Everything else answers "not implemented", which the Dart side treats as +/// a no-op. final class PepoNativeChannel: NSObject, AVAudioPlayerDelegate { private let channel: FlutterMethodChannel private var players: [AVAudioPlayer] = [] @@ -45,12 +50,69 @@ final class PepoNativeChannel: NSObject, AVAudioPlayerDelegate { return } result(self.play(wav.data)) + case "keepAliveStart": + result(KeepAlive.shared.start()) + case "keepAliveStop": + KeepAlive.shared.stop() + result(nil) + case "backgroundHold": + let on = (call.arguments as? [String: Any])?["on"] as? Bool ?? false + BackgroundHold.shared.set(on) + result(BackgroundHold.shared.isHeld) + case "backgroundStatus": + result(self.status()) + case "backgroundReady": + // Native → Dart calls made before the Dart handler exists sit in the + // channel buffer unanswered, so nothing is pushed until this arrives. + self.listenForTasks() + result(nil) + case "openAppSettings": + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + result(nil) + case "backgroundTaskDone": + let args = call.arguments as? [String: Any] + let identifier = args?["id"] as? String ?? "" + let ok = args?["ok"] as? Bool ?? true + PepoTasks.shared.complete(identifier, success: ok) + result(nil) default: result(FlutterMethodNotImplemented) } } } + private func listenForTasks() { + PepoTasks.shared.onTask = { [weak self] identifier in + self?.channel.invokeMethod("backgroundTask", arguments: ["id": identifier]) + } + PepoTasks.shared.flush() + } + + private func status() -> [String: Any] { + let engine = KeepAlive.shared + var out: [String: Any] = [ + "running": engine.running, + "playing": engine.isPlaying, + "held": BackgroundHold.shared.isHeld, + "refresh": PepoNativeChannel.refreshState(), + ] + if let started = engine.startedAt { + out["startedAt"] = Int(started.timeIntervalSince1970 * 1000) + } + return out + } + + private static func refreshState() -> String { + switch UIApplication.shared.backgroundRefreshStatus { + case .available: return "available" + case .denied: return "denied" + case .restricted: return "restricted" + @unknown default: return "unknown" + } + } + private func play(_ data: Data) -> Bool { do { let player = try AVAudioPlayer(data: data, fileTypeHint: AVFileType.wav.rawValue) @@ -65,5 +127,7 @@ final class PepoNativeChannel: NSObject, AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { players.removeAll { $0 === player } + // Never deactivate the session here: the chime shares it with the + // keep-alive loop, and tearing it down would suspend the app. } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6d02603..7ab4333 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,11 @@ + BGTaskSchedulerPermittedIdentifiers + + org.pepoconnect.app.refresh + org.pepoconnect.app.sync + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -80,6 +85,8 @@ UIBackgroundModes + audio + fetch processing UIFileSharingEnabled diff --git a/ios/Runner/PepoBackground.swift b/ios/Runner/PepoBackground.swift new file mode 100644 index 0000000..d8ecdbd --- /dev/null +++ b/ios/Runner/PepoBackground.swift @@ -0,0 +1,359 @@ +import AVFoundation +import BackgroundTasks +import UIKit + +/// Keeps the process alive while PepoConnect is not on screen. +/// +/// iOS has no API that wakes a suspended app when a photo is taken: +/// `PHPhotoLibraryChangeObserver` only delivers to a running process, and the +/// `BGTaskScheduler` tasks run when the system feels like it — minutes or +/// hours later. The only way for a new photo to leave the phone on its own is +/// for the process never to be suspended, and the `audio` background mode is +/// the one way to get there without asking for a permission a transfer app has +/// no business asking for. +/// +/// So this plays one second of silence on a loop. The samples are zeroes, the +/// session mixes with whatever else is playing and we never publish +/// now-playing info, so nothing is audible, other apps keep their audio and no +/// indicator appears. What it buys is a process iOS does not suspend, which is +/// all the Dart engine needs: the photo library observer, the TLS control +/// socket and the transfers keep running exactly as they do in the foreground. +final class KeepAlive: NSObject { + static let shared = KeepAlive() + + /// The watchdog beat. Short enough to recover from an interruption before + /// anyone notices, long enough to cost nothing. + private static let watchdogInterval: TimeInterval = 30 + + private var player: AVAudioPlayer? + private var watchdog: Timer? + private var observing = false + + /// Whether the engine is meant to be running. The watchdog only revives the + /// player while this is true. + private(set) var running = false + + /// When the loop started. It resets with the process, so "running for 47 + /// minutes" is proof from the phone itself that iOS never suspended it — + /// no cable, no Xcode. + private(set) var startedAt: Date? + + var isPlaying: Bool { player?.isPlaying ?? false } + + @discardableResult + func start() -> Bool { + observe() + if running { return resume() } + running = true + let ok = resume() + if ok { startedAt = Date() } + startWatchdog() + return ok + } + + func stop() { + running = false + watchdog?.invalidate() + watchdog = nil + player?.stop() + player = nil + startedAt = nil + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + } + + /// Brings the session and the player back up. Safe to call at any time: + /// everything here is idempotent, which is what lets the watchdog and the + /// notifications all funnel into the same path. + @discardableResult + private func resume() -> Bool { + guard running else { return false } + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + NSLog("PepoConnect: audio session failed: \(error.localizedDescription)") + return false + } + if player == nil { + do { + let made = try AVAudioPlayer( + data: KeepAlive.silence, fileTypeHint: AVFileType.wav.rawValue) + made.numberOfLoops = -1 + made.volume = 0.01 + made.prepareToPlay() + player = made + } catch { + NSLog("PepoConnect: silent player failed: \(error.localizedDescription)") + return false + } + } + guard let player = player else { return false } + return player.isPlaying || player.play() + } + + // --------------------------------------------------------------------------- + // Recovery + + private func startWatchdog() { + watchdog?.invalidate() + let timer = Timer(timeInterval: KeepAlive.watchdogInterval, repeats: true) { [weak self] _ in + guard let self = self, self.running, !self.isPlaying else { return } + if self.resume() && self.startedAt == nil { self.startedAt = Date() } + } + // Common modes: the beat must survive whatever the run loop is doing. + RunLoop.main.add(timer, forMode: .common) + watchdog = timer + } + + private func observe() { + guard !observing else { return } + observing = true + let center = NotificationCenter.default + center.addObserver( + self, selector: #selector(onInterruption(_:)), + name: AVAudioSession.interruptionNotification, object: nil) + center.addObserver( + self, selector: #selector(onMediaReset(_:)), + name: AVAudioSession.mediaServicesWereResetNotification, object: nil) + center.addObserver( + self, selector: #selector(onRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, object: nil) + center.addObserver( + self, selector: #selector(onEnterBackground(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil) + center.addObserver( + self, selector: #selector(onEnterForeground(_:)), + name: UIApplication.willEnterForegroundNotification, object: nil) + // Flutter runs on the scene lifecycle now; both sets fire today, and + // everything they call is idempotent, so listen to both rather than bet + // on which one keeps firing. + center.addObserver( + self, selector: #selector(onEnterBackground(_:)), + name: UIScene.didEnterBackgroundNotification, object: nil) + center.addObserver( + self, selector: #selector(onEnterForeground(_:)), + name: UIScene.willEnterForegroundNotification, object: nil) + } + + /// A phone call or another app taking the session over. `.ended` is the + /// normal way back; when it never arrives the watchdog picks it up. + @objc private func onInterruption(_ note: Notification) { + guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: raw) + else { return } + if type == .ended { resume() } + } + + /// The media server died and took the session and the player with it. + @objc private func onMediaReset(_ note: Notification) { + player = nil + resume() + } + + @objc private func onRouteChange(_ note: Notification) { + resume() + } + + @objc private func onEnterBackground(_ note: Notification) { + resume() + PepoTasks.shared.schedule() + } + + @objc private func onEnterForeground(_ note: Notification) { + resume() + } + + // --------------------------------------------------------------------------- + + /// One second of 16-bit mono PCM silence in a RIFF container, built in code + /// so there is no asset to lose when the IPA is re-signed for sideloading. + private static let silence: Data = KeepAlive.makeSilence(seconds: 1, sampleRate: 44100) + + private static func makeSilence(seconds: Int, sampleRate: Int) -> Data { + let channels = 1 + let bitsPerSample = 16 + let blockAlign = channels * bitsPerSample / 8 + let byteRate = sampleRate * blockAlign + let dataSize = byteRate * seconds + var out = Data(capacity: 44 + dataSize) + func ascii(_ text: String) { out.append(contentsOf: Array(text.utf8)) } + // Little endian by hand: no unsafe pointers to get wrong in a file that + // only ever gets compiled on CI. + func u32(_ value: Int) { + let v = UInt32(value) + out.append(contentsOf: [ + UInt8(v & 0xFF), + UInt8((v >> 8) & 0xFF), + UInt8((v >> 16) & 0xFF), + UInt8((v >> 24) & 0xFF), + ]) + } + func u16(_ value: Int) { + let v = UInt16(value) + out.append(contentsOf: [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF)]) + } + ascii("RIFF") + u32(36 + dataSize) + ascii("WAVE") + ascii("fmt ") + u32(16) + u16(1) // PCM + u16(channels) + u32(sampleRate) + u32(byteRate) + u16(blockAlign) + u16(bitsPerSample) + ascii("data") + u32(dataSize) + out.append(Data(count: dataSize)) + return out + } +} + +/// A background-task assertion around work that must not be cut short. +/// +/// [KeepAlive] makes this mostly redundant, but a transfer that starts just as +/// the app leaves the screen still deserves the ~30 s iOS grants on request — +/// and when the engine is off, or has just been interrupted, this is the only +/// thing holding the process up. +final class BackgroundHold { + static let shared = BackgroundHold() + + private var token: UIBackgroundTaskIdentifier = .invalid + + var isHeld: Bool { token != .invalid } + + func set(_ on: Bool) { + if on { + begin() + } else { + end() + } + } + + private func begin() { + guard token == .invalid else { return } + token = UIApplication.shared.beginBackgroundTask(withName: "pepoconnect.transfer") { + [weak self] in + self?.end() + } + } + + private func end() { + guard token != .invalid else { return } + UIApplication.shared.endBackgroundTask(token) + token = .invalid + } +} + +/// The opportunistic tier: `BGAppRefreshTask` and `BGProcessingTask`. +/// +/// These are the safety net for when [KeepAlive] is not running — the app was +/// closed from the app switcher, the phone rebooted, iOS reclaimed the +/// process. The system decides when they run, which in practice means minutes +/// to hours, so nothing about "take a photo and it arrives" rests on them: +/// they exist so a backlog eventually drains on its own. +/// +/// When iOS launches the app straight into the background for one of these, +/// the scene may never connect and the Flutter engine never starts. Then +/// nobody answers and [answerTimeout] hands the task back rather than leaving +/// it hanging until the system kills it. +final class PepoTasks { + static let shared = PepoTasks() + + static let refreshIdentifier = "org.pepoconnect.app.refresh" + static let syncIdentifier = "org.pepoconnect.app.sync" + + /// How long a task waits for Dart before we give it back. + private static let answerTimeout: TimeInterval = 25 + + /// Earliest the system may run the next one. + private static let earliest: TimeInterval = 15 * 60 + + /// Set by the channel once Dart is listening. + var onTask: ((String) -> Void)? + + private var active: [String: BGTask] = [:] + private var timeouts: [String: Timer] = [:] + private var undelivered: [String] = [] + private var registered = false + + /// Must run before `didFinishLaunchingWithOptions` returns. + func register() { + guard !registered else { return } + registered = true + for identifier in [PepoTasks.refreshIdentifier, PepoTasks.syncIdentifier] { + let ok = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { + [weak self] task in + self?.run(task) + } + if !ok { + // Refused identifiers fail silently later on submit, so say it once here. + NSLog("PepoConnect: cannot register background task \(identifier)") + } + } + } + + func schedule() { + guard registered else { return } + let refresh = BGAppRefreshTaskRequest(identifier: PepoTasks.refreshIdentifier) + refresh.earliestBeginDate = Date(timeIntervalSinceNow: PepoTasks.earliest) + let sync = BGProcessingTaskRequest(identifier: PepoTasks.syncIdentifier) + sync.requiresNetworkConnectivity = true + sync.requiresExternalPower = false + sync.earliestBeginDate = Date(timeIntervalSinceNow: PepoTasks.earliest) + for request in [refresh, sync] as [BGTaskRequest] { + do { + // Submitting over a pending request for the same identifier is an + // error on some releases; dropping it first never is. + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: request.identifier) + try BGTaskScheduler.shared.submit(request) + } catch { + NSLog( + "PepoConnect: cannot schedule \(request.identifier): \(error.localizedDescription)") + } + } + } + + /// Anything that arrived before Dart was listening. + func flush() { + let pending = undelivered + undelivered.removeAll() + for identifier in pending { deliver(identifier) } + } + + func complete(_ identifier: String, success: Bool) { + timeouts.removeValue(forKey: identifier)?.invalidate() + // A task handed back on the timeout must not be handed to Dart later. + undelivered.removeAll { $0 == identifier } + guard let task = active.removeValue(forKey: identifier) else { return } + task.setTaskCompleted(success: success) + } + + private func run(_ task: BGTask) { + let identifier = task.identifier + // Ask for the next one first: a task that ends without rescheduling is + // the last one the app ever gets. + schedule() + active[identifier] = task + task.expirationHandler = { [weak self] in + self?.complete(identifier, success: false) + } + let timer = Timer(timeInterval: PepoTasks.answerTimeout, repeats: false) { + [weak self] _ in + self?.complete(identifier, success: false) + } + RunLoop.main.add(timer, forMode: .common) + timeouts[identifier] = timer + deliver(identifier) + } + + private func deliver(_ identifier: String) { + guard let handler = onTask else { + undelivered.append(identifier) + return + } + handler(identifier) + } +} diff --git a/lib/app/app_services.dart b/lib/app/app_services.dart index 15c06db..f75cf47 100644 --- a/lib/app/app_services.dart +++ b/lib/app/app_services.dart @@ -14,7 +14,9 @@ import '../platform/android_service.dart'; import '../platform/autostart.dart'; import '../platform/clipboard_sync.dart'; import '../platform/desktop_integration.dart'; +import '../platform/ios_background.dart'; import '../platform/media_source_photo_manager.dart'; +import '../platform/mobile_permissions.dart'; import '../platform/notifications.dart'; import '../platform/open_helper.dart'; import '../platform/pepo_native.dart'; @@ -66,6 +68,7 @@ class AppServices with WidgetsBindingObserver { ProviderSubscription? _transfersSub; ProviderSubscription>? _devicesSub; AppLocalizations? _l10n; + Timer? _exportCleanup; bool _started = false; PepoEngine get engine => ref.read(engineProvider); @@ -95,6 +98,12 @@ class AppServices with WidgetsBindingObserver { if (DesktopIntegration.isSupported) { DesktopIntegration.instance.setActiveTransfers(next.activeCount); } + // iOS: hold the process up for as long as something is moving, so a + // transfer started right before the app left the screen still lands. + if (IosBackground.isSupported) { + unawaited(IosBackground.hold(next.active.isNotEmpty)); + } + if (next.active.isEmpty) _scheduleExportCleanup(); }); _devicesSub = ref.listen>(devicesProvider, _onDevices); final clip = ref.read(clipboardSyncProvider); @@ -109,13 +118,68 @@ class AppServices with WidgetsBindingObserver { if (PepoNative.isSupported) { // Quick-settings tile / launcher shortcut hand the clipboard over here. PepoNative.onClipboardText = _onNativeClipboard; + PepoNative.onBackgroundTask = _onBackgroundTask; PepoNative.init(); await PepoNative.markClipboardReady(); + await PepoNative.markBackgroundReady(); } if (Autostart.isSupported) unawaited(Autostart.refresh()); SystemNotifications.instance.onTap = _onNotificationTap; WidgetsBinding.instance.addObserver(this); - await _updateService(ref.read(devicesProvider)); + await _applyAutoSend(); + await _updateBackground(ref.read(devicesProvider)); + } + + /// Re-applies the phone's "send every new photo" target from the setting. + /// + /// The engine keeps it in memory only, so without this the switch reads on + /// after a restart while nothing is ever sent — which is precisely the case + /// that matters once the app is off screen. + Future _applyAutoSend({String? previousHubId}) async { + final hub = settings.defaultHubId; + if (previousHubId != null && previousHubId != hub) { + await engine.setAutoSend(previousHubId, false); + } + if (hub == null) return; + await engine.setAutoSend(hub, settings.autoSendPhotos); + } + + /// Sending a photo means exporting it out of the gallery to a temporary + /// file first, and those copies are never cleaned up while the process + /// lives — which, with the background engine, is days. Wipe them once + /// nothing is queued or in flight and could still be pointing at one. + void _scheduleExportCleanup() { + if (!MobilePermissions.isMobile) return; + _exportCleanup?.cancel(); + _exportCleanup = Timer(const Duration(minutes: 2), () { + final idle = + ref.read(transfersProvider).active.isEmpty && + (engine.mediaServer?.autoSend.isEmpty ?? true); + if (!idle) { + _scheduleExportCleanup(); + return; + } + unawaited(MediaSourcePhotoManager.clearExportedOriginals()); + }); + } + + /// iOS handed us a `BGAppRefreshTask` / `BGProcessingTask`: reconnect, + /// push whatever is queued and give the task straight back. The budget for + /// the next ones depends on not overstaying. + Future _onBackgroundTask(String id) async { + try { + engine.reconnectAll(); + final deadline = DateTime.now().add(const Duration(seconds: 8)); + while (DateTime.now().isBefore(deadline) && + !engine.sessions.sessions.any((s) => s.isConnected)) { + await Future.delayed(const Duration(milliseconds: 500)); + } + await engine.flushAutoSend().timeout(const Duration(seconds: 12)); + } catch (_) { + // Out of time or nothing reachable: the queue keeps it for next time. + } finally { + await PepoNative.backgroundTaskDone(id); + } } /// Back to the foreground: reconnect, re-read the photo library if the @@ -124,6 +188,8 @@ class AppServices with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed) return; engine.reconnectAll(); + // Back from a kill or a long sleep: anything the queue still owes. + unawaited(engine.flushAutoSend()); // Started behind the Android service: the permission dialog waited for a window. unawaited(SystemNotifications.instance.promptOnce()); final source = engine.ownMediaSource; @@ -234,12 +300,15 @@ class AppServices with WidgetsBindingObserver { unawaited(engine.setDeviceName(name)); } if (prev?.backgroundService != next.backgroundService) { - unawaited(_updateService(ref.read(devicesProvider))); + unawaited(_updateBackground(ref.read(devicesProvider))); + } + if (prev?.autoSendPhotos != next.autoSendPhotos || prev?.defaultHubId != next.defaultHubId) { + unawaited(_applyAutoSend(previousHubId: prev?.defaultHubId)); } } void _onDevices(List? prev, List next) { - unawaited(_updateService(next)); + unawaited(_updateBackground(next)); // The shared-clipboard switch can arrive from the peer (device.info); // it lands after the connection event, so this is the trigger that // works the first time. It also turns the global switch on, which the @@ -255,10 +324,22 @@ class AppServices with WidgetsBindingObserver { _pushClipboard(); } - Future _updateService(List devices) async { + /// The background engine: a foreground service on Android, the silent + /// keep-alive loop on iOS. One switch, one rule — there is nothing to hold + /// the process up for with no device paired. + Future _updateBackground(List devices) async { + final wanted = settings.backgroundService && devices.isNotEmpty; + if (IosBackground.isSupported) { + if (wanted) { + await IosBackground.start(); + } else { + await IosBackground.stop(); + } + return; + } if (!AndroidService.isSupported) return; final l = _l10n; - if (settings.backgroundService && devices.isNotEmpty) { + if (wanted) { final connected = devices.where((d) => d.connected).map((d) => d.device.name).toList(); final idle = l?.mobileServiceIdle ?? 'PepoConnect espera al PC'; final text = connected.isEmpty @@ -503,6 +584,7 @@ class AppServices with WidgetsBindingObserver { Future dispose() async { WidgetsBinding.instance.removeObserver(this); + _exportCleanup?.cancel(); for (final s in _subs) { await s.cancel(); } diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index ce8c642..a238847 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:pepo_core/pepo_core.dart'; +import '../platform/bonjour_discovery.dart'; import '../state/app_settings.dart'; import '../state/stores.dart'; @@ -137,6 +138,7 @@ Future startEngine({ List mediaRoots = const [], OfferPolicy? offerPolicy, DeletePolicy? deletePolicy, + bool bonjour = true, }) async { final identity = await defaultDeviceIdentity(); final pkg = await PackageInfo.fromPlatform(); @@ -166,9 +168,14 @@ Future startEngine({ ); final engine = PepoEngine( config, + // The UDP beacon cannot run on iPhone (broadcast needs an entitlement + // Apple does not hand out), so mDNS is the only way an iPhone finds a PC + // that changed address. Both ends have to announce for that to work. + discovery: [if (config.udpDiscovery) UdpBeacon(), if (bonjour) BonjourDiscovery()], deviceStore: JsonDeviceStore(paths.dataDir), transferStore: JsonTransferStore(paths.dataDir), mediaStateStore: JsonMediaStateStore(paths.dataDir), + autoSendStore: JsonAutoSendStore(paths.dataDir), mediaSource: mediaSource, offerPolicy: offerPolicy, deletePolicy: deletePolicy, diff --git a/lib/features/settings/permissions.dart b/lib/features/settings/permissions.dart index ad8518c..7dbca82 100644 --- a/lib/features/settings/permissions.dart +++ b/lib/features/settings/permissions.dart @@ -44,15 +44,21 @@ class PermissionRows extends ConsumerWidget { return const Column(children: [_LoadingRow(), _LoadingRow(), _LoadingRow()]); } final granted = value ?? _none; + // A limited selection reads as "granted" to iOS but hides every photo + // taken afterwards, and only the system settings can widen it. + final limited = granted.photos && granted.photosLimited; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ PermissionRow( icon: FluentIcons.image_multiple_24_regular, title: t.setPermissionPhotos, - description: t.setPermissionPhotosBody, - granted: granted.photos, - onAllow: () => _request(ref, MobilePermission.photos), + description: limited ? t.setPermissionPhotosLimited : t.setPermissionPhotosBody, + granted: granted.photos && !limited, + allowLabel: limited ? t.setOpenSystemSettings : null, + onAllow: () => limited + ? MobilePermissions.openSystemSettings() + : _request(ref, MobilePermission.photos), ), Divider(height: 1, color: context.pepo.divider), PermissionRow( @@ -90,6 +96,7 @@ class PermissionRow extends StatelessWidget { required this.description, required this.granted, required this.onAllow, + this.allowLabel, }); final IconData icon; @@ -98,6 +105,9 @@ class PermissionRow extends StatelessWidget { final bool granted; final VoidCallback onAllow; + /// Overrides the "Allow" label when the fix is somewhere else. + final String? allowLabel; + @override Widget build(BuildContext context) { final t = context.t; @@ -118,7 +128,7 @@ class PermissionRow extends StatelessWidget { ], ), ) - : FluentButton(label: t.setAllow, onPressed: onAllow), + : FluentButton(label: allowLabel ?? t.setAllow, onPressed: onAllow), ); } } diff --git a/lib/features/settings/sections/phone_section.dart b/lib/features/settings/sections/phone_section.dart index 66fbb3a..4d8ef60 100644 --- a/lib/features/settings/sections/phone_section.dart +++ b/lib/features/settings/sections/phone_section.dart @@ -1,23 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../platform/ios_background.dart'; import '../../../shared/i18n/l10n.dart'; import '../../../shared/motion/toast.dart'; import '../../../shared/theme/tokens.dart'; +import '../../../shared/util/format.dart'; import '../../../shared/widgets/device_icon.dart'; import '../../../shared/widgets/dropdown_button_fluent.dart'; import '../../../shared/widgets/toggle_switch.dart'; import '../../../state/app_settings.dart'; import '../../../state/engine_providers.dart'; import '../permissions.dart'; -import '../settings_providers.dart'; import '../settings_widgets.dart'; +/// Polls the iOS background engine, so the phone can show on its own screen +/// how long the process has been alive. Without it there is no way to tell a +/// working keep-alive from a silently suspended app short of a Mac. +final iosBackgroundProvider = StreamProvider.autoDispose((ref) async* { + if (!IosBackground.isSupported) return; + yield await IosBackground.status(); + yield* Stream.periodic(const Duration(seconds: 5)).asyncMap((_) => IosBackground.status()); +}); + /// Rows that only make sense on the phone: automatic sending, the -/// background service, the main PC and the permission state. +/// background engine, the main PC and the permission state. class PhoneSection extends ConsumerWidget { const PhoneSection({super.key}); + /// Only the setting is written here: `AppServices` owns applying it to the + /// engine, so it lands the same way at every start and not just when the + /// switch is touched. Future _toggleAutoSend(BuildContext context, WidgetRef ref, bool value) async { final t = context.t; final settings = ref.read(settingsProvider); @@ -31,19 +44,12 @@ class PhoneSection extends ConsumerWidget { await ref .read(settingsProvider.notifier) .update((s) => s.copyWith(autoSendPhotos: value, defaultHubId: hub)); - ref.read(settingsActionsProvider).setAutoSend(hub, value); } Future _setHub(WidgetRef ref, String hubId) async { final settings = ref.read(settingsProvider); - final previous = settings.defaultHubId; - if (previous == hubId) return; + if (settings.defaultHubId == hubId) return; await ref.read(settingsProvider.notifier).update((s) => s.copyWith(defaultHubId: hubId)); - if (settings.autoSendPhotos) { - final actions = ref.read(settingsActionsProvider); - if (previous != null) actions.setAutoSend(previous, false); - actions.setAutoSend(hubId, true); - } } @override @@ -53,6 +59,7 @@ class PhoneSection extends ConsumerWidget { final devices = ref.watch(devicesProvider); final notifier = ref.read(settingsProvider.notifier); final hubKnown = devices.any((d) => d.deviceId == settings.defaultHubId); + final onIos = IosBackground.isSupported; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -69,12 +76,18 @@ class PhoneSection extends ConsumerWidget { ), SettingsRow( title: t.setBackgroundService, - description: t.setBackgroundServiceBody, + description: onIos ? t.setBackgroundServiceBodyIos : t.setBackgroundServiceBody, trailing: ToggleSwitch( value: settings.backgroundService, onChanged: (v) => notifier.update((s) => s.copyWith(backgroundService: v)), ), ), + if (onIos) + SettingsRow( + title: t.setBackgroundEngine, + description: t.setBackgroundEngineBody, + trailing: const _BackgroundEngineState(), + ), SettingsRow( title: t.setDefaultHub, description: t.setDefaultHubBody, @@ -103,3 +116,34 @@ class PhoneSection extends ConsumerWidget { ); } } + +/// "Activo desde hace 47 min" and the two ways it can fall short of that. +class _BackgroundEngineState extends ConsumerWidget { + const _BackgroundEngineState(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = context.t; + final colors = context.pepo; + final state = ref.watch(iosBackgroundProvider).value ?? IosBackgroundState.off; + final String label; + final Color color; + final started = state.startedAt; + if (!state.running) { + label = t.setBackgroundEngineOff; + color = colors.textSecondary; + } else if (!state.playing) { + // The session was taken away (a call, another app) and the watchdog + // has not won it back yet. + label = t.setBackgroundEngineRecovering; + color = colors.caution; + } else if (started == null) { + label = t.setBackgroundEngineStarting; + color = colors.success; + } else { + label = t.setBackgroundEngineOn(formatShortDuration(DateTime.now().difference(started))); + color = colors.success; + } + return Text(label, style: context.text.caption.copyWith(color: color)); + } +} diff --git a/lib/features/settings/settings_providers.dart b/lib/features/settings/settings_providers.dart index dfb00d1..9d3a3cf 100644 --- a/lib/features/settings/settings_providers.dart +++ b/lib/features/settings/settings_providers.dart @@ -87,11 +87,6 @@ class SettingsActions { _ref.invalidate(localDeviceFactsProvider); } - /// Mobile: forward every new photo to [hubId] (or stop doing so). - void setAutoSend(String hubId, bool enabled) { - _ref.read(engineProvider).setAutoSend(hubId, enabled); - } - /// Every preference back to its default. Paired devices, the window /// geometry and the onboarding flag are kept. Future resetAll() async { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7316998..4ff1b1c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -424,6 +424,15 @@ "setChooseFolder": "Choose where to save received files", "setBackgroundService": "Background service", "setBackgroundServiceBody": "Keeps the connection to the PC", + "setBackgroundServiceBodyIos": "Keeps PepoConnect running with the app closed so photos leave on their own. It uses some battery.", + "setBackgroundEngine": "Background state", + "setBackgroundEngineOn": "Running for {uptime}", + "setBackgroundEngineStarting": "Starting…", + "setBackgroundEngineRecovering": "Recovering…", + "setBackgroundEngineOff": "Stopped", + "setBackgroundEngineBody": "Closing PepoConnect from the app switcher stops it until you open it again.", + "setPermissionPhotosLimited": "Limited access: PepoConnect only sees the photos you picked, so new ones are never sent.", + "setOpenSystemSettings": "Open Settings", "setAutoSendPhotos": "Send new photos automatically", "setAutoSendPhotosBody": "Every photo you take reaches the main PC right away", "setDefaultHub": "Main PC", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cb82b75..8a0be1b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -954,6 +954,22 @@ "setChooseFolder": "Elige dónde guardar los archivos recibidos", "setBackgroundService": "Servicio en segundo plano", "setBackgroundServiceBody": "Mantiene la conexión con el PC", + "setBackgroundServiceBodyIos": "Mantiene PepoConnect en marcha con la app cerrada para que las fotos salgan solas. Gasta algo de batería.", + "setBackgroundEngine": "Estado del segundo plano", + "setBackgroundEngineOn": "Activo desde hace {uptime}", + "@setBackgroundEngineOn": { + "placeholders": { + "uptime": { + "type": "String" + } + } + }, + "setBackgroundEngineStarting": "Arrancando…", + "setBackgroundEngineRecovering": "Recuperándose…", + "setBackgroundEngineOff": "Parado", + "setBackgroundEngineBody": "Si cierras PepoConnect desde el selector de apps, deja de funcionar hasta que la vuelvas a abrir.", + "setPermissionPhotosLimited": "Acceso limitado: PepoConnect solo ve las fotos que elegiste, así que las nuevas no se envían.", + "setOpenSystemSettings": "Abrir Ajustes", "setAutoSendPhotos": "Enviar fotos nuevas automáticamente", "setAutoSendPhotosBody": "Cada foto que hagas llega al PC principal al momento", "setDefaultHub": "PC principal", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 1418945..ef30efb 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2553,6 +2553,60 @@ abstract class AppLocalizations { /// **'Mantiene la conexión con el PC'** String get setBackgroundServiceBody; + /// No description provided for @setBackgroundServiceBodyIos. + /// + /// In es, this message translates to: + /// **'Mantiene PepoConnect en marcha con la app cerrada para que las fotos salgan solas. Gasta algo de batería.'** + String get setBackgroundServiceBodyIos; + + /// No description provided for @setBackgroundEngine. + /// + /// In es, this message translates to: + /// **'Estado del segundo plano'** + String get setBackgroundEngine; + + /// No description provided for @setBackgroundEngineOn. + /// + /// In es, this message translates to: + /// **'Activo desde hace {uptime}'** + String setBackgroundEngineOn(String uptime); + + /// No description provided for @setBackgroundEngineStarting. + /// + /// In es, this message translates to: + /// **'Arrancando…'** + String get setBackgroundEngineStarting; + + /// No description provided for @setBackgroundEngineRecovering. + /// + /// In es, this message translates to: + /// **'Recuperándose…'** + String get setBackgroundEngineRecovering; + + /// No description provided for @setBackgroundEngineOff. + /// + /// In es, this message translates to: + /// **'Parado'** + String get setBackgroundEngineOff; + + /// No description provided for @setBackgroundEngineBody. + /// + /// In es, this message translates to: + /// **'Si cierras PepoConnect desde el selector de apps, deja de funcionar hasta que la vuelvas a abrir.'** + String get setBackgroundEngineBody; + + /// No description provided for @setPermissionPhotosLimited. + /// + /// In es, this message translates to: + /// **'Acceso limitado: PepoConnect solo ve las fotos que elegiste, así que las nuevas no se envían.'** + String get setPermissionPhotosLimited; + + /// No description provided for @setOpenSystemSettings. + /// + /// In es, this message translates to: + /// **'Abrir Ajustes'** + String get setOpenSystemSettings; + /// No description provided for @setAutoSendPhotos. /// /// In es, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 37d913c..ffb247a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1505,6 +1505,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setBackgroundServiceBody => 'Keeps the connection to the PC'; + @override + String get setBackgroundServiceBodyIos => + 'Keeps PepoConnect running with the app closed so photos leave on their own. It uses some battery.'; + + @override + String get setBackgroundEngine => 'Background state'; + + @override + String setBackgroundEngineOn(String uptime) { + return 'Running for $uptime'; + } + + @override + String get setBackgroundEngineStarting => 'Starting…'; + + @override + String get setBackgroundEngineRecovering => 'Recovering…'; + + @override + String get setBackgroundEngineOff => 'Stopped'; + + @override + String get setBackgroundEngineBody => + 'Closing PepoConnect from the app switcher stops it until you open it again.'; + + @override + String get setPermissionPhotosLimited => + 'Limited access: PepoConnect only sees the photos you picked, so new ones are never sent.'; + + @override + String get setOpenSystemSettings => 'Open Settings'; + @override String get setAutoSendPhotos => 'Send new photos automatically'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index d9e664e..47e2d46 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1506,6 +1506,38 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setBackgroundServiceBody => 'Mantiene la conexión con el PC'; + @override + String get setBackgroundServiceBodyIos => + 'Mantiene PepoConnect en marcha con la app cerrada para que las fotos salgan solas. Gasta algo de batería.'; + + @override + String get setBackgroundEngine => 'Estado del segundo plano'; + + @override + String setBackgroundEngineOn(String uptime) { + return 'Activo desde hace $uptime'; + } + + @override + String get setBackgroundEngineStarting => 'Arrancando…'; + + @override + String get setBackgroundEngineRecovering => 'Recuperándose…'; + + @override + String get setBackgroundEngineOff => 'Parado'; + + @override + String get setBackgroundEngineBody => + 'Si cierras PepoConnect desde el selector de apps, deja de funcionar hasta que la vuelvas a abrir.'; + + @override + String get setPermissionPhotosLimited => + 'Acceso limitado: PepoConnect solo ve las fotos que elegiste, así que las nuevas no se envían.'; + + @override + String get setOpenSystemSettings => 'Abrir Ajustes'; + @override String get setAutoSendPhotos => 'Enviar fotos nuevas automáticamente'; diff --git a/lib/main.dart b/lib/main.dart index caaacae..ff56633 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; -import 'package:pepo_core/pepo_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app/app.dart'; @@ -30,16 +29,18 @@ Future main(List args) async { final settings = SettingsNotifier.load(prefs); final paths = await AppPaths.resolve(options); - MediaSource? mediaSource; - if (Platform.isAndroid || Platform.isIOS) { - mediaSource = MediaSourcePhotoManager(cacheDir: p.join(paths.cacheDir, 'own')); - } + final photos = Platform.isAndroid || Platform.isIOS + ? MediaSourcePhotoManager(cacheDir: p.join(paths.cacheDir, 'own')) + : null; final engine = await startEngine( settings: settings, paths: paths, options: options, - mediaSource: mediaSource, + mediaSource: photos, ); + // The engine only has the slot for HEIC -> JPEG; the conversion itself is + // platform code, so it has to be handed over here. + if (photos != null) engine.mediaServer?.convertHeicToJpeg = photos.convertHeicToJpeg; if (DesktopIntegration.isSupported) { final b = settings.windowBounds; diff --git a/lib/platform/bonjour_discovery.dart b/lib/platform/bonjour_discovery.dart new file mode 100644 index 0000000..63ba74a --- /dev/null +++ b/lib/platform/bonjour_discovery.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:bonsoir/bonsoir.dart'; +import 'package:logging/logging.dart'; +import 'package:pepo_core/pepo_core.dart'; + +final _log = Logger('pepo.discovery.bonjour'); + +/// The service PepoConnect advertises. Declared in `ios/Runner/Info.plist` +/// under `NSBonjourServices`, without which iOS refuses to browse for it. +const String pepoServiceType = '_pepoconnect._tcp'; + +/// Finds peers over Bonjour/mDNS, on every platform. +/// +/// The UDP beacon cannot run on iPhone: since iOS 14 broadcast and multicast +/// need an entitlement Apple grants case by case, and never to a free +/// account. Without this an iPhone can only ever dial the last address it saw, +/// so a PC that reboots onto a new DHCP lease disappears for good. mDNS asks +/// for nothing beyond the local-network permission the app already has. +/// +/// It runs on the desktops and on Android too, because the discovery only +/// works if the other end is announcing itself. +class BonjourDiscovery implements Discovery { + BonjourDiscovery({ + this.serviceType = pepoServiceType, + this.rediscoverAfter = const Duration(seconds: 20), + }); + + final String serviceType; + + /// A probe restarts the browse at most this often. mDNS keeps listening on + /// its own, so restarting more than that is churn for nothing. + final Duration rediscoverAfter; + + final _found = StreamController.broadcast(); + + /// Last candidate seen per device, so a probe can answer from memory + /// instead of waiting for the network to say it again. + final Map _seen = {}; + + BonsoirBroadcast? _broadcast; + BonsoirDiscovery? _browser; + StreamSubscription? _events; + StreamSubscription? _broadcastEvents; + DiscoveryAdvert? _advert; + DateTime? _browsingSince; + bool _running = false; + int _nameAttempt = 0; + + @override + Stream get found => _found.stream; + + bool get isRunning => _running; + + @override + Future start(DiscoveryAdvert advert) async { + _advert = advert; + if (_running) return; + _running = true; + await _startBroadcast(); + await _startBrowse(); + } + + @override + Future updateAdvert(DiscoveryAdvert advert) async { + final previous = _advert; + _advert = advert; + if (!_running) return; + if (previous != null && previous.name == advert.name && previous.port == advert.port) { + return; + } + _nameAttempt = 0; + await _stopBroadcast(); + await _startBroadcast(); + } + + @override + Future stop() async { + _running = false; + await _stopBroadcast(); + await _stopBrowse(); + _seen.clear(); + } + + void dispose() { + unawaited(stop()); + _found.close(); + } + + /// mDNS has no probe of its own: what it can do is hand back what it + /// already knows right away, and start the browse over when it has been + /// running long enough that a restart might turn something up. + @override + Future probe() async { + if (!_running) return; + final advert = _advert; + for (final candidate in _seen.values) { + if (advert != null && candidate.deviceId == advert.deviceId) continue; + if (!_found.isClosed) _found.add(candidate); + } + final since = _browsingSince; + if (_browser == null || since == null || DateTime.now().difference(since) > rediscoverAfter) { + await _stopBrowse(); + await _startBrowse(); + } + } + + // --------------------------------------------------------------------------- + + Future _startBroadcast() async { + final advert = _advert; + if (advert == null || advert.port <= 0) return; + try { + final broadcast = BonsoirBroadcast( + printLogs: false, + service: BonsoirService( + name: _serviceName(advert), + type: serviceType, + port: advert.port, + attributes: { + 'id': advert.deviceId, + 'fp16': advert.shortFingerprint, + 'name': advert.name, + 'platform': advert.platform.code, + 'role': advert.role.code, + }, + ), + ); + await broadcast.initialize(); + _broadcastEvents = broadcast.eventStream?.listen(_onBroadcastEvent); + await broadcast.start(); + _broadcast = broadcast; + } catch (e) { + // No Avahi, no permission, no mDNS stack: the other backends carry on. + _log.info('cannot announce over mDNS: $e'); + await _stopBroadcast(); + } + } + + /// Another device on the network already answers to this name. Most + /// platforms rename silently; the ones that do not say so here. + void _onBroadcastEvent(BonsoirBroadcastEvent event) { + if (event is! BonsoirBroadcastNameAlreadyExistsEvent) return; + if (_nameAttempt >= 3) return; + _nameAttempt++; + unawaited(() async { + await _stopBroadcast(); + await _startBroadcast(); + }()); + } + + Future _stopBroadcast() async { + final broadcast = _broadcast; + _broadcast = null; + await _broadcastEvents?.cancel(); + _broadcastEvents = null; + if (broadcast == null || broadcast.isStopped) return; + try { + await broadcast.stop(); + } catch (_) {} + } + + Future _startBrowse() async { + try { + final browser = BonsoirDiscovery(printLogs: false, type: serviceType); + await browser.initialize(); + _events = browser.eventStream?.listen((event) => _onEvent(browser, event)); + await browser.start(); + _browser = browser; + _browsingSince = DateTime.now(); + } catch (e) { + _log.info('cannot browse over mDNS: $e'); + await _stopBrowse(); + } + } + + Future _stopBrowse() async { + final browser = _browser; + _browser = null; + _browsingSince = null; + await _events?.cancel(); + _events = null; + if (browser == null || browser.isStopped) return; + try { + await browser.stop(); + } catch (_) {} + } + + void _onEvent(BonsoirDiscovery browser, BonsoirDiscoveryEvent event) { + switch (event) { + case BonsoirDiscoveryServiceFoundEvent(): + // Found only carries the name; the address and the TXT record need + // a second round trip. + unawaited(_resolve(browser, event.service)); + case BonsoirDiscoveryServiceResolvedEvent(): + _publish(event.service); + case BonsoirDiscoveryServiceUpdatedEvent(): + _publish(event.service); + case BonsoirDiscoveryServiceLostEvent(): + _seen.removeWhere((_, c) => c.name == event.service.name); + default: + break; + } + } + + Future _resolve(BonsoirDiscovery browser, BonsoirService service) async { + try { + await service.resolve(browser.serviceResolver); + } catch (e) { + _log.fine('cannot resolve ${service.name}: $e'); + } + } + + void _publish(BonsoirService service) { + final advert = _advert; + final candidate = candidateFromService(service, selfId: advert?.deviceId); + if (candidate == null) return; + _seen[candidate.deviceId] = candidate; + if (!_found.isClosed) _found.add(candidate); + } + + /// `Mi iPhone` for the phone next to you, and something that still works + /// when two of them are called the same: the id in the TXT record is what + /// identifies the device, the name is only what other Bonjour browsers + /// show. + String _serviceName(DiscoveryAdvert advert) { + final base = advert.name.trim().isEmpty ? 'PepoConnect' : advert.name.trim(); + final name = _nameAttempt == 0 + ? base + : '$base (${advert.deviceId.substring(advert.deviceId.length - 4)})'; + // RFC 6763 allows 63 bytes; accented characters take two. + return name.length > 40 ? name.substring(0, 40) : name; + } +} + +/// Turns a resolved mDNS service into a candidate, or null when it is not +/// one of ours, is this very device, or has not been resolved yet. +/// +/// Kept apart from the plugin so the mapping can be tested without a +/// platform channel. +PeerCandidate? candidateFromService(BonsoirService service, {String? selfId, DateTime? at}) { + final attributes = service.attributes; + final id = attributes['id']; + if (id == null || id.length != 26 || id == selfId) return null; + if (service.port <= 0) return null; + final addresses = usableAddresses(service.hostAddresses); + if (addresses.isEmpty) return null; + return PeerCandidate( + deviceId: id, + shortFingerprint: attributes['fp16'] ?? '', + name: attributes['name'] ?? service.name, + addresses: addresses, + port: service.port, + platform: DevicePlatform.fromCode(attributes['platform']), + role: DeviceRole.fromCode(attributes['role']), + source: DiscoverySource.mdns, + seenAt: at ?? DateTime.now(), + ); +} + +/// Drops what cannot be dialled: APIPA, IPv6 link-local (the scope id the +/// peer reports means nothing on this side) and loopback. +List usableAddresses(List addresses) { + final out = []; + for (final address in addresses) { + final value = address.trim(); + if (value.isEmpty) continue; + if (value.startsWith('169.254.') || value == '127.0.0.1') continue; + final lower = value.toLowerCase(); + if (lower.startsWith('fe80:') || lower == '::1') continue; + if (!out.contains(value)) out.add(value); + } + // IPv4 first: it is what the rest of the app pairs and reconnects with. + out.sort((a, b) => (a.contains(':') ? 1 : 0).compareTo(b.contains(':') ? 1 : 0)); + return out; +} diff --git a/lib/platform/ios_background.dart b/lib/platform/ios_background.dart new file mode 100644 index 0000000..f225b0b --- /dev/null +++ b/lib/platform/ios_background.dart @@ -0,0 +1,89 @@ +import 'pepo_native.dart'; + +/// What the iOS side reports about the background engine. +class IosBackgroundState { + const IosBackgroundState({ + this.running = false, + this.playing = false, + this.held = false, + this.refresh = 'unknown', + this.startedAt, + }); + + /// The engine is meant to be running. + final bool running; + + /// The silent loop really is playing. Running without playing means iOS + /// took the audio session away and the watchdog has not won it back yet. + final bool playing; + + /// A `beginBackgroundTask` assertion is held (a transfer is in flight). + final bool held; + + /// `available`, `denied`, `restricted` or `unknown`: the system switch for + /// Background App Refresh, which gates the `BGTask` safety net. + final String refresh; + + /// When the loop started. It resets with the process, so how long it has + /// been running is the phone's own proof that iOS never suspended it. + final DateTime? startedAt; + + bool get healthy => running && playing; + + static const IosBackgroundState off = IosBackgroundState(); + + factory IosBackgroundState.fromMap(Map map) { + final started = map['startedAt']; + return IosBackgroundState( + running: map['running'] as bool? ?? false, + playing: map['playing'] as bool? ?? false, + held: map['held'] as bool? ?? false, + refresh: map['refresh'] as String? ?? 'unknown', + startedAt: started is int ? DateTime.fromMillisecondsSinceEpoch(started) : null, + ); + } +} + +/// The iOS counterpart of the Android foreground service: what keeps the +/// engine in this isolate running once PepoConnect leaves the screen. +/// +/// iOS never wakes a suspended app for a new photo — no API does that — so +/// the only way a photo leaves the phone on its own is for the process not to +/// be suspended in the first place. [start] plays an inaudible loop under the +/// `audio` background mode to get exactly that, and everything else (the +/// photo library observer, the control socket, the transfers) keeps working +/// untouched. +/// +/// What it does not survive: the app being closed from the app switcher, or a +/// reboot. Then the pending photos wait in the auto-send queue until the app +/// is opened again, or until iOS grants one of the `BGTask`s. +class IosBackground { + const IosBackground._(); + + static bool get isSupported => PepoNative.isIos; + + /// Starts the keep-alive engine. False when the audio session refused, + /// which the watchdog on the native side will keep retrying. + static Future start() async { + if (!isSupported) return false; + return PepoNative.keepAliveStart(); + } + + static Future stop() async { + if (!isSupported) return; + await PepoNative.keepAliveStop(); + } + + /// Holds the process up for the ~30 s iOS grants on request. Cheap enough + /// to leave held for as long as a transfer runs. + static Future hold(bool on) async { + if (!isSupported) return; + await PepoNative.backgroundHold(on); + } + + static Future status() async { + if (!isSupported) return IosBackgroundState.off; + final raw = await PepoNative.backgroundStatus(); + return raw == null ? IosBackgroundState.off : IosBackgroundState.fromMap(raw); + } +} diff --git a/lib/platform/media_source_photo_manager.dart b/lib/platform/media_source_photo_manager.dart index 843593e..d9310b6 100644 --- a/lib/platform/media_source_photo_manager.dart +++ b/lib/platform/media_source_photo_manager.dart @@ -51,30 +51,34 @@ class MediaSourcePhotoManager extends MediaSource { @override Stream get changes => _changes.stream; + static const _permission = pm.PermissionRequestOption( + androidPermission: pm.AndroidPermission(type: pm.RequestType.common, mediaLocation: true), + iosAccessLevel: pm.IosAccessLevel.readWrite, + ); + /// Asks for photo permissions. Returns true when at least limited access /// was granted. static Future requestPermission() async { - final state = await pm.PhotoManager.requestPermissionExtend( - requestOption: const pm.PermissionRequestOption( - androidPermission: pm.AndroidPermission(type: pm.RequestType.common, mediaLocation: true), - iosAccessLevel: pm.IosAccessLevel.readWrite, - ), - ); + final state = await pm.PhotoManager.requestPermissionExtend(requestOption: _permission); return state.hasAccess; } /// Current permission state without prompting. - static Future hasPermission() async { + static Future hasPermission() async => (await _state())?.hasAccess ?? false; + + /// True when iOS granted only a hand-picked selection. Photos taken later + /// are invisible to the app then, so automatic sending quietly does + /// nothing and the only fix is in the system settings. + static Future permissionLimited() async { + if (!Platform.isIOS) return false; + return await _state() == pm.PermissionState.limited; + } + + static Future _state() async { try { - final state = await pm.PhotoManager.getPermissionState( - requestOption: const pm.PermissionRequestOption( - androidPermission: pm.AndroidPermission(type: pm.RequestType.common, mediaLocation: true), - iosAccessLevel: pm.IosAccessLevel.readWrite, - ), - ); - return state.hasAccess; + return await pm.PhotoManager.getPermissionState(requestOption: _permission); } catch (_) { - return false; + return null; } } @@ -300,6 +304,19 @@ class MediaSourcePhotoManager extends MediaSource { } } + /// Wipes the copies the platform makes when an original is exported. + /// + /// [originalPath] cannot hand out a path into the photo library, so every + /// photo that gets sent is first written to the app's temporary folder. + /// They are never cleaned up while the process lives, and with the + /// background engine the process lives for days. Only `photo_manager`'s own + /// cache folders are removed, never anything of the user's. + static Future clearExportedOriginals() async { + try { + await pm.PhotoManager.clearFileCache(); + } catch (_) {} + } + /// Converts a HEIC/HEIF file to JPEG next to the cache dir, keeping EXIF. Future convertHeicToJpeg(String path) async { try { diff --git a/lib/platform/mobile_permissions.dart b/lib/platform/mobile_permissions.dart index e055748..143f278 100644 --- a/lib/platform/mobile_permissions.dart +++ b/lib/platform/mobile_permissions.dart @@ -10,13 +10,19 @@ class MobilePermissionState { required this.photos, required this.notifications, required this.batteryUnrestricted, + this.photosLimited = false, }); final bool photos; final bool notifications; final bool batteryUnrestricted; - bool get allGranted => photos && notifications && batteryUnrestricted; + /// iOS only: access was granted to a hand-picked selection. It counts as + /// [photos] for iOS, but new photos never show up, so automatic sending + /// silently does nothing. + final bool photosLimited; + + bool get allGranted => photos && !photosLimited && notifications && batteryUnrestricted; } /// Requests and checks Android/iOS permissions (photos, notifications, @@ -36,6 +42,7 @@ class MobilePermissions { } return MobilePermissionState( photos: await MediaSourcePhotoManager.hasPermission(), + photosLimited: await MediaSourcePhotoManager.permissionLimited(), notifications: await SystemNotifications.instance.enabled(), batteryUnrestricted: Platform.isIOS || await PepoNative.isIgnoringBatteryOptimizations(), ); @@ -52,4 +59,8 @@ class MobilePermissions { if (!Platform.isAndroid) return; await PepoNative.requestIgnoreBatteryOptimizations(); } + + /// Only the system settings can turn a limited photo selection into full + /// access; the in-app picker just adds more photos to the selection. + static Future openSystemSettings() => PepoNative.openAppSettings(); } diff --git a/lib/platform/pepo_native.dart b/lib/platform/pepo_native.dart index d8c109c..5c922a7 100644 --- a/lib/platform/pepo_native.dart +++ b/lib/platform/pepo_native.dart @@ -1,26 +1,46 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// Thin wrapper over the `org.pepoconnect/native` channel (`PepoNative.kt` on -/// Android, `AppDelegate.swift` on iOS). Every call is a no-op elsewhere. +/// Android, `AppDelegate.swift` + `PepoBackground.swift` on iOS). Every call +/// is a no-op elsewhere. class PepoNative { const PepoNative._(); static const _channel = MethodChannel('org.pepoconnect/native'); - static bool get _available => Platform.isAndroid; + /// Which native side to talk to. Only tests set it; everywhere else it is + /// the platform the app is really running on. + @visibleForTesting + static String? debugPlatform; - /// Android has the native side of this channel. - static bool get isSupported => _available; + static String get _os => debugPlatform ?? Platform.operatingSystem; + + /// The foreground service, the clipboard tile, MediaStore: Android only. + static bool get _android => _os == 'android'; + + /// The background engine: iPhone only. + static bool get isIos => _os == 'ios'; + + /// The chime and the background engine exist on both phones. + static bool get _mobile => _android || isIos; + + /// True where the channel has a native side at all. + static bool get isSupported => _mobile; /// Clipboard text handed over by the native side (Android quick-settings /// tile / launcher shortcut). Returns `{'sent': [device names]}`. static Future> Function(String text)? onClipboardText; + /// iOS handed us a `BGAppRefreshTask` / `BGProcessingTask`. Whatever this + /// does must finish quickly and end with [backgroundTaskDone]. + static Future Function(String id)? onBackgroundTask; + /// Listens for native → Dart calls. Idempotent. static void init() { - if (!_available) return; + if (!_mobile) return; _channel.setMethodCallHandler(_onCall); } @@ -31,6 +51,12 @@ class PepoNative { final handler = onClipboardText; if (handler == null || text.isEmpty) return {'sent': []}; return handler(text); + case 'backgroundTask': + final args = (call.arguments as Map?)?.cast(); + final id = args?['id'] as String? ?? ''; + final handler = onBackgroundTask; + if (handler != null) await handler(id); + return null; default: throw MissingPluginException('${call.method} is not handled in Dart'); } @@ -40,7 +66,7 @@ class PepoNative { /// before the handler exists sits in the channel buffer unanswered, so /// the native side must not use the channel until it hears this. static Future markClipboardReady() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('clipboardReady'); } catch (_) {} @@ -52,7 +78,7 @@ class PepoNative { /// permission dialogs) have nobody to answer them then, and some never /// complete. Always true where there is no such service. static Future hasActivity() async { - if (!_available) return true; + if (!_android) return true; try { return await _channel.invokeMethod('hasActivity') ?? false; } catch (_) { @@ -68,7 +94,7 @@ class PepoNative { required String text, required String idle, }) async { - if (!_available) return false; + if (!_android) return false; try { return await _channel.invokeMethod('serviceStart', { 'title': title, @@ -82,7 +108,7 @@ class PepoNative { } static Future serviceUpdate({required String title, required String text}) async { - if (!_available) return false; + if (!_android) return false; try { return await _channel.invokeMethod('serviceUpdate', {'title': title, 'text': text}) ?? false; @@ -92,14 +118,14 @@ class PepoNative { } static Future serviceStop() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('serviceStop'); } catch (_) {} } static Future serviceRunning() async { - if (!_available) return false; + if (!_android) return false; try { return await _channel.invokeMethod('serviceRunning') ?? false; } catch (_) { @@ -107,22 +133,86 @@ class PepoNative { } } + // --------------------------------------------------------------------------- + // iOS background engine + + /// Tells iOS that [init] ran, so it may push `backgroundTask` calls. Also + /// hands over any task that arrived while nobody was listening. + static Future markBackgroundReady() async { + if (!isIos) return; + try { + await _channel.invokeMethod('backgroundReady'); + } catch (_) {} + } + + /// Starts the silent-audio loop that keeps the process out of iOS's + /// suspended state. False when the audio session refused. + static Future keepAliveStart() async { + if (!isIos) return false; + try { + return await _channel.invokeMethod('keepAliveStart') ?? false; + } catch (_) { + return false; + } + } + + static Future keepAliveStop() async { + if (!isIos) return; + try { + await _channel.invokeMethod('keepAliveStop'); + } catch (_) {} + } + + /// Holds (or releases) a `beginBackgroundTask` assertion: the ~30 s iOS + /// grants on request, so a transfer in flight is not cut off when the app + /// leaves the screen. + static Future backgroundHold(bool on) async { + if (!isIos) return false; + try { + return await _channel.invokeMethod('backgroundHold', {'on': on}) ?? false; + } catch (_) { + return false; + } + } + + /// `{running, playing, held, refresh, startedAt}`; null off iOS. + static Future?> backgroundStatus() async { + if (!isIos) return null; + try { + final raw = await _channel.invokeMethod>('backgroundStatus'); + return raw?.cast(); + } catch (_) { + return null; + } + } + + /// Hands a `BGTask` back to iOS. Not answering it wastes the app's budget + /// for the next ones. + static Future backgroundTaskDone(String id, {bool ok = true}) async { + if (!isIos) return; + try { + await _channel.invokeMethod('backgroundTaskDone', {'id': id, 'ok': ok}); + } catch (_) {} + } + + // --------------------------------------------------------------------------- + static Future acquireMulticastLock() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('acquireMulticastLock'); } catch (_) {} } static Future releaseMulticastLock() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('releaseMulticastLock'); } catch (_) {} } static Future isIgnoringBatteryOptimizations() async { - if (!_available) return true; + if (!_android) return true; try { return await _channel.invokeMethod('isIgnoringBatteryOptimizations') ?? true; } catch (_) { @@ -131,7 +221,7 @@ class PepoNative { } static Future requestIgnoreBatteryOptimizations() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('requestIgnoreBatteryOptimizations'); } catch (_) {} @@ -140,7 +230,7 @@ class PepoNative { /// Copies a received file into `Download/PepoConnect` through MediaStore /// so it shows up in the Files app. Returns the content URI or null. static Future saveToDownloads(String path, String name, String mime) async { - if (!_available) return null; + if (!_android) return null; try { return await _channel.invokeMethod('saveToDownloads', { 'path': path, @@ -155,7 +245,7 @@ class PepoNative { /// Plays a short 16-bit mono WAV (the transfer chime) on the notification /// stream. Android and iOS; false when it could not be played. static Future playWav(Uint8List wav, {required int sampleRate}) async { - if (!Platform.isAndroid && !Platform.isIOS) return false; + if (!_mobile) return false; try { return await _channel.invokeMethod('playWav', {'wav': wav, 'sampleRate': sampleRate}) ?? false; @@ -165,7 +255,7 @@ class PepoNative { } static Future openAppSettings() async { - if (!_available) return; + if (!_mobile) return; try { await _channel.invokeMethod('openAppSettings'); } catch (_) {} diff --git a/lib/shared/util/format.dart b/lib/shared/util/format.dart index 6a147cc..1b2edc6 100644 --- a/lib/shared/util/format.dart +++ b/lib/shared/util/format.dart @@ -87,6 +87,16 @@ String _time(DateTime local, String locale) { } } +/// "18 s" / "47 min" / "3 h 12 min": how long something has been going. +/// The units read the same in Spanish and English, so this needs no +/// localization. +String formatShortDuration(Duration d) { + if (d.inMinutes < 1) return '${d.inSeconds.clamp(0, 59)} s'; + if (d.inHours < 1) return '${d.inMinutes} min'; + final minutes = d.inMinutes.remainder(60); + return minutes == 0 ? '${d.inHours} h' : '${d.inHours} h $minutes min'; +} + /// "1:05" / "12:07:30" for video lengths. String formatDuration(Duration d) { final h = d.inHours; diff --git a/lib/state/stores.dart b/lib/state/stores.dart index 331167f..a418746 100644 --- a/lib/state/stores.dart +++ b/lib/state/stores.dart @@ -179,6 +179,31 @@ class JsonTransferStore implements TransferStore { } } +/// The phone's auto-send queue in `autosend.json`. Written through (not +/// just debounced): its whole point is to survive the process dying. +class JsonAutoSendStore implements AutoSendStore { + JsonAutoSendStore(String dataDir) : _file = JsonFile(p.join(dataDir, 'autosend.json')); + + final JsonFile _file; + + @override + Future load() async { + final raw = await _file.read(); + if (raw is! Map) return AutoSendData.empty; + try { + return AutoSendData.fromJson(raw.cast()); + } catch (_) { + return AutoSendData.empty; + } + } + + @override + Future save(AutoSendData data) async { + _file.write(data.toJson()); + await _file.flush(); + } +} + /// Per-device gallery item states in `gallery/.json`: /// `{"seenUntil": , "items": [...]}`. Files written before the /// watermark existed are a bare list and still load. diff --git a/packages/pepo_core/lib/pepo_core.dart b/packages/pepo_core/lib/pepo_core.dart index 04b41ca..4bcf8cb 100644 --- a/packages/pepo_core/lib/pepo_core.dart +++ b/packages/pepo_core/lib/pepo_core.dart @@ -9,6 +9,7 @@ export 'src/engine/pepo_engine.dart'; export 'src/identity/certificate_factory.dart'; export 'src/identity/identity.dart'; export 'src/identity/identity_store.dart'; +export 'src/media/auto_send_queue.dart'; export 'src/media/gallery_client.dart'; export 'src/media/image_ops.dart'; export 'src/media/media_server.dart' show MediaServer, DeletePolicy; diff --git a/packages/pepo_core/lib/src/engine/pepo_engine.dart b/packages/pepo_core/lib/src/engine/pepo_engine.dart index 2930353..6f19b42 100644 --- a/packages/pepo_core/lib/src/engine/pepo_engine.dart +++ b/packages/pepo_core/lib/src/engine/pepo_engine.dart @@ -9,11 +9,12 @@ import '../discovery/discovery.dart'; import '../discovery/udp_beacon.dart'; import '../identity/identity.dart'; import '../identity/identity_store.dart'; -import '../native/native_bulk.dart'; +import '../media/auto_send_queue.dart'; import '../media/gallery_client.dart'; import '../media/media_server.dart'; import '../media/media_source.dart'; import '../media/media_source_fs.dart'; +import '../native/native_bulk.dart'; import '../net/frame.dart'; import '../net/handshake.dart'; import '../net/peer_connection.dart'; @@ -68,6 +69,7 @@ class PepoEngine { TransferStore? transferStore, MediaStateStore? mediaStateStore, MediaSource? mediaSource, + AutoSendStore? autoSendStore, List? discovery, DeviceStatus Function()? localStatus, OfferPolicy? offerPolicy, @@ -79,6 +81,8 @@ class PepoEngine { // ignore: prefer_initializing_formals _mediaSource = mediaSource, // ignore: prefer_initializing_formals + _autoSendStore = autoSendStore, + // ignore: prefer_initializing_formals _discovery = discovery, // ignore: prefer_initializing_formals _localStatus = localStatus, @@ -93,6 +97,7 @@ class PepoEngine { final TransferStore _transferStore; final MediaStateStore _mediaStateStore; MediaSource? _mediaSource; + final AutoSendStore? _autoSendStore; final List? _discovery; final DeviceStatus Function()? _localStatus; final OfferPolicy? _offerPolicy; @@ -194,6 +199,7 @@ class PepoEngine { sessions: sessions, transfers: transfers, deletePolicy: _deletePolicy, + autoSendStore: _autoSendStore, ); sessions.handlers.add(mediaServer!); await mediaServer!.start(); @@ -214,6 +220,9 @@ class PepoEngine { case DeviceConnectedEvent(): _emit(DeviceConnectionEvent(deviceId: e.deviceId, connected: true)); _emit(DevicesChangedEvent()); + // Whatever piled up while this device was away. + final server = mediaServer; + if (server != null) unawaited(server.autoSend.drain()); case DeviceDisconnectedEvent(): _emit(DeviceConnectionEvent(deviceId: e.deviceId, connected: false, reason: e.reason)); _emit(DevicesChangedEvent()); @@ -667,16 +676,17 @@ class PepoEngine { MediaSource? get ownMediaSource => _mediaSource; /// Enables/disables automatic sending of new photos to [deviceId]. - void setAutoSend(String deviceId, bool enabled) { + Future setAutoSend(String deviceId, bool enabled) async { + await mediaServer?.setAutoSend(deviceId, enabled); + } + + /// Picks up whatever this phone owes its hubs and sends what it can: on + /// reconnect, on start, and when iOS hands the app a background task. + Future flushAutoSend() async { final server = mediaServer; if (server == null) return; - final set = server.autoSendTo ?? {}; - if (enabled) { - set.add(deviceId); - } else { - set.remove(deviceId); - } - server.autoSendTo = set; + await server.catchUp(); + await server.autoSend.drain(); } } diff --git a/packages/pepo_core/lib/src/media/auto_send_queue.dart b/packages/pepo_core/lib/src/media/auto_send_queue.dart new file mode 100644 index 0000000..e79d547 --- /dev/null +++ b/packages/pepo_core/lib/src/media/auto_send_queue.dart @@ -0,0 +1,378 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; + +final _log = Logger('pepo.media.autosend'); + +/// What one attempt at sending an item ended up as. +enum AutoSendResult { + /// Handed over to the transfer engine, which owns it from here. + sent, + + /// The item is not there any more (deleted, no readable original): drop it. + skip, + + /// Could not be sent right now. It stays queued. + retry, +} + +/// One item waiting to go to one device. +class AutoSendEntry { + const AutoSendEntry({ + required this.deviceId, + required this.itemId, + required this.queuedAt, + this.attempts = 0, + }); + + final String deviceId; + final String itemId; + final DateTime queuedAt; + + /// Failed tries so far. A photo that never makes it must not wedge the + /// queue behind it forever. + final int attempts; + + String get key => '$deviceId/$itemId'; + + AutoSendEntry retried() => + AutoSendEntry(deviceId: deviceId, itemId: itemId, queuedAt: queuedAt, attempts: attempts + 1); + + Map toJson() => { + 'deviceId': deviceId, + 'itemId': itemId, + 'at': queuedAt.toUtc().toIso8601String(), + 'tries': attempts, + }; + + factory AutoSendEntry.fromJson(Map json) => AutoSendEntry( + deviceId: json['deviceId'] as String, + itemId: json['itemId'] as String, + queuedAt: DateTime.tryParse(json['at'] as String? ?? '') ?? DateTime.now(), + attempts: json['tries'] as int? ?? 0, + ); +} + +/// Everything the queue keeps across restarts. +class AutoSendData { + const AutoSendData({ + this.enabledSince, + this.catchUpAt, + this.pending = const [], + this.done = const [], + }); + + /// When automatic sending was switched on. Nothing older is ever queued, + /// so turning the switch on does not push the whole camera roll to the PC. + final DateTime? enabledSince; + + /// Newest item the catch-up pass has already looked at. This is what makes + /// a photo taken while the process was dead arrive at the next start + /// instead of being silently skipped. + final DateTime? catchUpAt; + + final List pending; + + /// `/` already handed to the transfer engine. + final List done; + + AutoSendData copyWith({ + DateTime? enabledSince, + DateTime? catchUpAt, + List? pending, + List? done, + bool clearEnabledSince = false, + }) => AutoSendData( + enabledSince: clearEnabledSince ? null : (enabledSince ?? this.enabledSince), + catchUpAt: catchUpAt ?? this.catchUpAt, + pending: pending ?? this.pending, + done: done ?? this.done, + ); + + Map toJson() => { + 'enabledSince': ?enabledSince?.toUtc().toIso8601String(), + 'catchUpAt': ?catchUpAt?.toUtc().toIso8601String(), + 'pending': pending.map((e) => e.toJson()).toList(), + 'done': done, + }; + + factory AutoSendData.fromJson(Map json) { + final pending = []; + for (final e in (json['pending'] as List? ?? const [])) { + try { + pending.add(AutoSendEntry.fromJson((e as Map).cast())); + } catch (_) {} + } + return AutoSendData( + enabledSince: DateTime.tryParse(json['enabledSince'] as String? ?? ''), + catchUpAt: DateTime.tryParse(json['catchUpAt'] as String? ?? ''), + pending: pending, + done: (json['done'] as List? ?? const []).cast(), + ); + } + + static const empty = AutoSendData(); +} + +/// Where [AutoSendQueue] keeps its state. +abstract class AutoSendStore { + Future load(); + Future save(AutoSendData data); +} + +/// In-memory store (tests, and any engine without a data directory). +class MemoryAutoSendStore implements AutoSendStore { + AutoSendData _data = AutoSendData.empty; + + @override + Future load() async => _data; + + @override + Future save(AutoSendData data) async => _data = data; +} + +/// Makes "send every new photo to the PC" a promise instead of a coin flip. +/// +/// Sending straight from the photo-library notification only works when the +/// peer happens to be connected at that instant. With the PC asleep, the +/// Wi-Fi switching over, or the phone's process freshly restarted, the photo +/// was simply dropped and never retried. This queue writes every pending item +/// to disk, hands it over as soon as the device is reachable again, and +/// remembers what already went so a restart does not send it twice. +class AutoSendQueue { + AutoSendQueue({ + required this.store, + required this.send, + required this.isReachable, + this.maxPending = 200, + this.maxDone = 500, + this.maxAttempts = 8, + this.maxAge = const Duration(days: 7), + }); + + final AutoSendStore store; + + /// Tries to send one item. See [AutoSendResult]. + final Future Function(String deviceId, String itemId) send; + + /// Whether that device has a live session at this moment. + final bool Function(String deviceId) isReachable; + + /// A phone left offline for a week should not wake up and flood the PC. + final int maxPending; + final int maxDone; + final int maxAttempts; + final Duration maxAge; + + AutoSendData _data = AutoSendData.empty; + final Set _targets = {}; + + /// Every change to [_data] queues up behind this. A photo landing while a + /// reconnect is draining would otherwise have both sides write their own + /// version of the state, and one of them would be lost. + Future _writes = Future.value(); + bool _loaded = false; + bool _draining = false; + bool _again = false; + + /// Devices that get every new photo automatically. + Set get targets => Set.unmodifiable(_targets); + + bool get isEmpty => _data.pending.isEmpty; + int get pendingCount => _data.pending.length; + + /// Nothing taken before this is ever queued. + DateTime? get enabledSince => _data.enabledSince; + + /// Where the catch-up pass should start looking. + DateTime? get catchUpFrom { + final since = _data.enabledSince; + final mark = _data.catchUpAt; + if (since == null) return mark; + if (mark == null) return since; + return mark.toUtc().isAfter(since.toUtc()) ? mark : since; + } + + Future load() async { + if (_loaded) return; + _loaded = true; + try { + _data = await store.load(); + } catch (e) { + _log.warning('cannot read the auto-send queue: $e'); + _data = AutoSendData.empty; + } + } + + /// Replaces the set of devices that get new photos. Switching it on for + /// the first time stamps [enabledSince]; switching it off entirely drops + /// whatever was still queued, because nobody asked for it any more. + Future setTargets(Iterable deviceIds, {DateTime? now}) async { + await load(); + final next = deviceIds.toSet(); + if (_setEquals(next, _targets)) return; + _targets + ..clear() + ..addAll(next); + await _mutate((data) { + if (_targets.isEmpty) { + return data.copyWith(pending: const [], clearEnabledSince: true); + } + var out = data; + // Only the first time it is switched on. Re-applying the setting at + // every start must not move the mark forward, or the photos taken + // while the process was dead would all fall before it. + if (out.enabledSince == null) { + final at = now ?? DateTime.now(); + out = out.copyWith(enabledSince: at, catchUpAt: at); + } + // Anything queued for a device that is no longer a target is dead mail. + return out.copyWith( + pending: out.pending.where((e) => _targets.contains(e.deviceId)).toList(), + ); + }, now: now); + } + + /// Queues [itemId] for every target. [takenAt] is when the photo was taken; + /// anything older than [enabledSince] is ignored. + Future add(String itemId, {DateTime? takenAt, DateTime? now}) async { + await load(); + if (_targets.isEmpty) return; + final since = _data.enabledSince; + if (takenAt != null && since != null && takenAt.toUtc().isBefore(since.toUtc())) return; + final at = now ?? DateTime.now(); + var added = false; + await _mutate((data) { + final pending = List.of(data.pending); + final queued = pending.map((e) => e.key).toSet(); + final done = data.done.toSet(); + for (final deviceId in _targets) { + final key = '$deviceId/$itemId'; + if (queued.contains(key) || done.contains(key)) continue; + pending.add(AutoSendEntry(deviceId: deviceId, itemId: itemId, queuedAt: at)); + added = true; + } + return added ? data.copyWith(pending: pending) : data; + }, now: at); + if (added) await drain(); + } + + /// Sends everything that can be sent right now. A device that refuses is + /// parked until the next call, so a PC that just went away does not burn + /// the rest of its queue. + Future drain() async { + await load(); + // A photo from last week is not something anyone is still waiting for, + // and nothing else would ever clear it out of a queue that never drains. + if (_prune(_data, DateTime.now()).pending.length != _data.pending.length) { + await _mutate((data) => data); + } + if (_draining) { + // A change landed mid-drain: go round again instead of dropping it. + _again = true; + return; + } + _draining = true; + try { + do { + _again = false; + await _drainOnce(); + } while (_again); + } finally { + _draining = false; + } + } + + Future _drainOnce() async { + if (_data.pending.isEmpty) return; + final parked = {}; + final sent = {}; + final dropped = {}; + final retried = {}; + for (final entry in List.of(_data.pending)) { + if (parked.contains(entry.deviceId)) continue; + if (!_targets.contains(entry.deviceId) || !isReachable(entry.deviceId)) continue; + AutoSendResult result; + try { + result = await send(entry.deviceId, entry.itemId); + } catch (e) { + _log.fine('auto-send of ${entry.itemId} failed: $e'); + result = AutoSendResult.retry; + } + switch (result) { + case AutoSendResult.sent: + sent.add(entry.key); + case AutoSendResult.skip: + dropped.add(entry.key); + case AutoSendResult.retry: + if (entry.attempts + 1 >= maxAttempts) { + _log.info('giving up on ${entry.itemId} after ${entry.attempts + 1} tries'); + dropped.add(entry.key); + } else { + retried.add(entry.key); + } + parked.add(entry.deviceId); + } + } + if (sent.isEmpty && dropped.isEmpty && retried.isEmpty) return; + final gone = {...sent, ...dropped}; + // Expressed against whatever the state is when this lands, not against + // the snapshot the loop above started from: a photo taken while these + // were on the wire has to survive. + await _mutate( + (data) => data.copyWith( + pending: [ + for (final e in data.pending) + if (!gone.contains(e.key)) (retried.contains(e.key) ? e.retried() : e), + ], + done: [...data.done, ...sent], + ), + ); + } + + /// Remembers how far the catch-up pass got, so the next start only looks + /// at what came after. + Future markCaughtUp(DateTime at) async { + await load(); + final current = _data.catchUpAt; + if (current != null && !at.toUtc().isAfter(current.toUtc())) return; + await _mutate((data) => data.copyWith(catchUpAt: at)); + } + + /// Applies [change] to the current state and writes it out, one at a time. + /// [change] is handed the state as it is when its turn comes, never a + /// snapshot taken earlier. + Future _mutate(AutoSendData Function(AutoSendData data) change, {DateTime? now}) { + final previous = _writes; + final mine = Completer(); + _writes = mine.future; + return previous + .then((_) async { + _data = _prune(change(_data), now ?? DateTime.now()); + try { + await store.save(_data); + } catch (e) { + _log.warning('cannot write the auto-send queue: $e'); + } + }) + .whenComplete(mine.complete); + } + + AutoSendData _prune(AutoSendData data, DateTime now) { + final cutoff = now.toUtc().subtract(maxAge); + var pending = data.pending + .where((e) => !e.queuedAt.toUtc().isBefore(cutoff)) + .toList(growable: false); + // Oldest first by construction: a backlog this long means the oldest are + // the least likely to still be wanted. + if (pending.length > maxPending) { + pending = pending.sublist(pending.length - maxPending); + } + var done = data.done; + if (done.length > maxDone) done = done.sublist(done.length - maxDone); + return data.copyWith(pending: pending, done: done); + } + + static bool _setEquals(Set a, Set b) => + a.length == b.length && a.every(b.contains); +} diff --git a/packages/pepo_core/lib/src/media/media_server.dart b/packages/pepo_core/lib/src/media/media_server.dart index 4ede4f0..7d7eea2 100644 --- a/packages/pepo_core/lib/src/media/media_server.dart +++ b/packages/pepo_core/lib/src/media/media_server.dart @@ -10,6 +10,7 @@ import '../protocol/message_types.dart'; import '../protocol/models.dart'; import '../transfer/transfer_engine.dart'; import '../util/bytes.dart'; +import 'auto_send_queue.dart'; import 'media_source.dart'; final _log = Logger('pepo.media.server'); @@ -28,8 +29,14 @@ class MediaServer implements MessageHandler { DeletePolicy? deletePolicy, this.thumbPx = 320, this.maxThumbBatch = 32, - this.autoSendTo, - }) : deletePolicy = deletePolicy ?? ((_, _) async => true); + AutoSendStore? autoSendStore, + }) : deletePolicy = deletePolicy ?? ((_, _) async => true) { + autoSend = AutoSendQueue( + store: autoSendStore ?? MemoryAutoSendStore(), + send: _autoSend, + isReachable: (deviceId) => sessions.session(deviceId)?.isConnected ?? false, + ); + } final MediaSource source; final SessionManager sessions; @@ -38,15 +45,24 @@ class MediaServer implements MessageHandler { final int thumbPx; final int maxThumbBatch; + /// New photos waiting to go to the hub. Persisted, so a photo taken while + /// the PC was off still arrives once it comes back. + late final AutoSendQueue autoSend; + /// Device ids that get every new photo sent automatically (phone-side - /// "auto send" setting). Null = none. - Set? autoSendTo; + /// "auto send" setting). + Set get autoSendTo => autoSend.targets; + + /// How far back the catch-up pass is willing to walk. Five pages of 100 is + /// a long weekend of photos; past that, the phone was not really syncing. + static const _maxCatchUpPages = 5; StreamSubscription? _sub; Future start() async { await source.start(); _sub = source.changes.listen(_onChange); + await autoSend.load(); } Future stop() async { @@ -55,6 +71,48 @@ class MediaServer implements MessageHandler { await source.stop(); } + /// Enables or disables automatic sending to [deviceId], then picks up + /// whatever happened while nobody was watching. + Future setAutoSend(String deviceId, bool enabled) async { + final next = {...autoSend.targets}; + if (enabled) { + next.add(deviceId); + } else { + next.remove(deviceId); + } + await autoSend.setTargets(next); + if (enabled) await catchUp(); + await autoSend.drain(); + } + + /// Queues everything that appeared while nothing was listening: the + /// process was killed, the phone rebooted, the app was closed from the + /// switcher. [MediaSource] hands out a fresh snapshot when it starts, so + /// without this pass those photos are never reported as new and would sit + /// on the phone forever. + Future catchUp() async { + if (autoSend.targets.isEmpty) return; + final since = autoSend.catchUpFrom; + if (since == null) return; + try { + var newest = since; + var page = 0; + for (var i = 0; i < _maxCatchUpPages; i++) { + final result = await source.index(page: page, pageSize: 100, since: since); + for (final item in result.items) { + await autoSend.add(item.id, takenAt: item.takenAt); + if (item.takenAt.isAfter(newest)) newest = item.takenAt; + } + // Newest first, so an empty page means we are past the watermark. + if (result.items.isEmpty || result.nextPage == null) break; + page = result.nextPage!; + } + await autoSend.markCaughtUp(newest); + } catch (e) { + _log.fine('auto-send catch-up failed: $e'); + } + } + Future _onChange(MediaChange change) async { if (change.removed.isNotEmpty) { for (final s in sessions.sessions) { @@ -73,11 +131,10 @@ class MediaServer implements MessageHandler { for (final s in sessions.sessions) { if (!s.isConnected) continue; s.control?.send(MsgType.mediaNew, data: {'item': item.toJson()}, body: thumb); - final auto = autoSendTo; - if (auto != null && auto.contains(s.deviceId)) { - unawaited(_sendOriginal(s.deviceId, item.id, convert: false)); - } } + // Queued rather than sent straight to whoever happens to be connected: + // that is what makes it survive a PC that is asleep right now. + await autoSend.add(item.id, takenAt: item.takenAt); } } @@ -133,8 +190,8 @@ class MediaServer implements MessageHandler { case MsgType.fileRequest: final id = m.str('id'); final convert = m.optStr('convert') == 'jpeg'; - final ok = await _sendOriginal(deviceId, id, convert: convert); - if (ok) { + final result = await _sendOriginal(deviceId, id, convert: convert); + if (result == AutoSendResult.sent) { conn.respond(m.reqId, MsgType.fileAccept, data: {'id': id}); } else { conn.respondError(m.reqId, ErrorCode.notFound, 'item $id not available'); @@ -153,10 +210,20 @@ class MediaServer implements MessageHandler { } } - Future _sendOriginal(String deviceId, String id, {required bool convert}) async { + /// One queued item. The HEIC setting is the one this phone keeps for that + /// PC, the same flag its own downloads use. + Future _autoSend(String deviceId, String itemId) { + final convert = sessions.session(deviceId)?.device.convertHeic ?? false; + return _sendOriginal(deviceId, itemId, convert: convert); + } + + Future _sendOriginal(String deviceId, String id, {required bool convert}) async { final item = await source.item(id); + if (item == null) return AutoSendResult.skip; final path = await source.originalPath(id); - if (item == null || path == null) return false; + // The item is there but the original is not readable yet: on iOS that is + // an asset still coming down from iCloud, which is worth another try. + if (path == null) return AutoSendResult.retry; var sendPath = path; var name = item.name; if (convert && _isHeic(item)) { @@ -175,10 +242,10 @@ class MediaServer implements MessageHandler { mediaKind: item.kind, sourceId: id, ); - return true; + return AutoSendResult.sent; } catch (e) { _log.warning('cannot send $id: $e'); - return false; + return AutoSendResult.retry; } } diff --git a/packages/pepo_core/test/auto_send_queue_test.dart b/packages/pepo_core/test/auto_send_queue_test.dart new file mode 100644 index 0000000..01ff3fc --- /dev/null +++ b/packages/pepo_core/test/auto_send_queue_test.dart @@ -0,0 +1,298 @@ +import 'dart:async'; + +import 'package:pepo_core/src/media/auto_send_queue.dart'; +import 'package:test/test.dart'; + +const hub = 'HUB00000000000000000000000'; +const other = 'OTH00000000000000000000000'; + +/// Records what the queue tried to send and answers with whatever the test +/// lined up for that item. +class FakeSender { + final List calls = []; + final Map answers = {}; + AutoSendResult fallback = AutoSendResult.sent; + Set reachable = {hub, other}; + + /// Holds the next send open, so a test can make something else happen + /// while it is in flight. + Completer? gate; + + Future send(String deviceId, String itemId) async { + calls.add('$deviceId/$itemId'); + final held = gate; + if (held != null) { + gate = null; + return held.future; + } + return answers['$deviceId/$itemId'] ?? fallback; + } + + bool isReachable(String deviceId) => reachable.contains(deviceId); +} + +AutoSendQueue queueOn( + FakeSender sender, + AutoSendStore store, { + int maxPending = 200, + int maxDone = 500, + int maxAttempts = 8, + Duration maxAge = const Duration(days: 7), +}) => AutoSendQueue( + store: store, + send: sender.send, + isReachable: sender.isReachable, + maxPending: maxPending, + maxDone: maxDone, + maxAttempts: maxAttempts, + maxAge: maxAge, +); + +void main() { + late FakeSender sender; + late MemoryAutoSendStore store; + late AutoSendQueue queue; + + setUp(() { + sender = FakeSender(); + store = MemoryAutoSendStore(); + queue = queueOn(sender, store); + }); + + test('a new photo goes out as soon as it is queued', () async { + await queue.setTargets([hub]); + await queue.add('photo-1'); + expect(sender.calls, ['$hub/photo-1']); + expect(queue.isEmpty, isTrue); + }); + + test('nothing is queued while the switch is off', () async { + await queue.add('photo-1'); + expect(sender.calls, isEmpty); + expect(queue.isEmpty, isTrue); + }); + + test('a photo taken with the PC off waits and goes out on reconnect', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + expect(sender.calls, isEmpty); + expect(queue.pendingCount, 1); + + sender.reachable = {hub}; + await queue.drain(); + expect(sender.calls, ['$hub/photo-1']); + expect(queue.isEmpty, isTrue); + }); + + test('the queue survives a restart and does not send twice', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + + // Same store, new process. + final restarted = queueOn(sender, store); + await restarted.load(); + await restarted.setTargets([hub]); + sender.reachable = {hub}; + await restarted.drain(); + expect(sender.calls, ['$hub/photo-1']); + + await restarted.add('photo-1'); + expect(sender.calls, ['$hub/photo-1'], reason: 'already sent once'); + }); + + test('re-applying the setting at start keeps the original enabledSince', () async { + final before = DateTime.utc(2026, 9, 1); + await queue.setTargets([hub], now: before); + expect(queue.enabledSince, before); + + final restarted = queueOn(sender, store); + await restarted.setTargets([hub], now: DateTime.utc(2026, 9, 18)); + expect( + restarted.enabledSince, + before, + reason: 'moving it forward would hide every photo taken while dead', + ); + }); + + test('photos older than the switch being turned on are left alone', () async { + await queue.setTargets([hub], now: DateTime.utc(2026, 9, 10)); + await queue.add('old', takenAt: DateTime.utc(2026, 9, 9)); + await queue.add('new', takenAt: DateTime.utc(2026, 9, 11)); + expect(sender.calls, ['$hub/new']); + }); + + test('turning the switch off drops what was still queued', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + expect(queue.pendingCount, 1); + + await queue.setTargets(const []); + expect(queue.pendingCount, 0); + expect(queue.enabledSince, isNull); + }); + + test('changing the hub drops the old one and keeps the new', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + await queue.setTargets([other]); + expect(queue.pendingCount, 0); + + sender.reachable = {other}; + await queue.add('photo-2'); + expect(sender.calls, ['$other/photo-2']); + }); + + test('every target gets its own copy', () async { + await queue.setTargets([hub, other]); + await queue.add('photo-1'); + expect(sender.calls, containsAll(['$hub/photo-1', '$other/photo-1'])); + expect(sender.calls.length, 2); + }); + + test('a deleted photo is dropped instead of retried forever', () async { + await queue.setTargets([hub]); + sender.answers['$hub/gone'] = AutoSendResult.skip; + await queue.add('gone'); + expect(queue.isEmpty, isTrue); + + await queue.drain(); + expect(sender.calls, ['$hub/gone'], reason: 'not tried again'); + }); + + test('a photo that keeps failing is given up on after maxAttempts', () async { + queue = queueOn(sender, store, maxAttempts: 3); + await queue.setTargets([hub]); + sender.fallback = AutoSendResult.retry; + await queue.add('stuck'); + expect(queue.pendingCount, 1); + await queue.drain(); + expect(queue.pendingCount, 1); + await queue.drain(); + expect(queue.pendingCount, 0, reason: 'three tries is enough'); + expect(sender.calls.length, 3); + }); + + test('one device refusing does not burn the other device queue', () async { + await queue.setTargets([hub, other]); + sender.reachable = {}; + await queue.add('photo-1'); + sender.reachable = {hub, other}; + sender.answers['$hub/photo-1'] = AutoSendResult.retry; + await queue.drain(); + expect(sender.calls, containsAll(['$hub/photo-1', '$other/photo-1'])); + expect(queue.pendingCount, 1, reason: 'only the hub entry is left'); + }); + + test('a backlog is capped at maxPending, newest kept', () async { + queue = queueOn(sender, store, maxPending: 3); + await queue.setTargets([hub]); + sender.reachable = {}; + for (var i = 0; i < 6; i++) { + await queue.add('photo-$i'); + } + expect(queue.pendingCount, 3); + + sender.reachable = {hub}; + await queue.drain(); + expect(sender.calls, ['$hub/photo-3', '$hub/photo-4', '$hub/photo-5']); + }); + + test('entries older than maxAge are forgotten on the next drain', () async { + // A queue file written days ago and loaded now. + final stale = DateTime.now().subtract(const Duration(days: 2)); + await store.save( + AutoSendData( + enabledSince: stale.subtract(const Duration(days: 1)), + pending: [AutoSendEntry(deviceId: hub, itemId: 'stale', queuedAt: stale)], + ), + ); + queue = queueOn(sender, store, maxAge: const Duration(hours: 1)); + await queue.load(); + expect(queue.pendingCount, 1); + + await queue.setTargets([hub]); + await queue.drain(); + expect(sender.calls, isEmpty); + expect(queue.pendingCount, 0); + }); + + test('the sent list is capped so it cannot grow without end', () async { + queue = queueOn(sender, store, maxDone: 2); + await queue.setTargets([hub]); + for (var i = 0; i < 5; i++) { + await queue.add('photo-$i'); + } + final saved = await store.load(); + expect(saved.done, ['$hub/photo-3', '$hub/photo-4']); + }); + + test('the catch-up mark moves forward only', () async { + final start = DateTime.utc(2026, 9, 10); + await queue.setTargets([hub], now: start); + expect(queue.catchUpFrom, start); + + await queue.markCaughtUp(DateTime.utc(2026, 9, 12)); + expect(queue.catchUpFrom, DateTime.utc(2026, 9, 12)); + + await queue.markCaughtUp(DateTime.utc(2026, 9, 11)); + expect(queue.catchUpFrom, DateTime.utc(2026, 9, 12)); + }); + + test('state round-trips through JSON', () async { + await queue.setTargets([hub], now: DateTime.utc(2026, 9, 10)); + sender.reachable = {}; + await queue.add('photo-1'); + final json = (await store.load()).toJson(); + final back = AutoSendData.fromJson(json); + expect(back.enabledSince, DateTime.utc(2026, 9, 10)); + expect(back.pending.single.deviceId, hub); + expect(back.pending.single.itemId, 'photo-1'); + }); + + test('a photo taken mid-drain is not lost', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + + sender.reachable = {hub}; + final held = Completer(); + sender.gate = held; + final draining = queue.drain(); + await Future.delayed(Duration.zero); + + // The camera fires while the first one is still on the wire. + await queue.add('photo-2'); + held.complete(AutoSendResult.sent); + await draining; + + expect(sender.calls, ['$hub/photo-1', '$hub/photo-2']); + expect(queue.isEmpty, isTrue); + }); + + test('a broken store does not take the queue down with it', () async { + queue = AutoSendQueue( + store: _BrokenStore(), + send: sender.send, + isReachable: sender.isReachable, + ); + await queue.setTargets([hub]); + await queue.add('photo-1'); + expect(sender.calls, ['$hub/photo-1']); + }); +} + +class _BrokenStore implements AutoSendStore { + @override + Future load() async => throw const FileSystemExceptionStub(); + + @override + Future save(AutoSendData data) async => throw const FileSystemExceptionStub(); +} + +class FileSystemExceptionStub implements Exception { + const FileSystemExceptionStub(); +} diff --git a/packages/pepo_core/test/media_test.dart b/packages/pepo_core/test/media_test.dart index 62a5cd7..30340aa 100644 --- a/packages/pepo_core/test/media_test.dart +++ b/packages/pepo_core/test/media_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:pepo_core/src/identity/certificate_factory.dart'; +import 'package:pepo_core/src/media/auto_send_queue.dart'; import 'package:pepo_core/src/media/gallery_client.dart'; import 'package:pepo_core/src/media/image_ops.dart'; import 'package:pepo_core/src/media/media_server.dart'; @@ -19,7 +20,125 @@ Future writeJpeg(Directory dir, String name, {int w = 640, int h = 480, in return f; } +/// Polls [ready] until it holds or the time is up. The media source reports +/// a change on its own schedule and the server handles it asynchronously, so +/// there is nothing single to await. +Future pumpUntil( + bool Function() ready, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (!ready() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 25)); + } +} + void main() { + group('auto send', () { + const hubId = 'HUB00000000000000000000000'; + late Directory root; + late Directory cache; + late MediaSourceFs source; + late SessionManager sessions; + late TransferEngine transfers; + late MemoryAutoSendStore store; + late MediaServer server; + + Future buildServer() async { + source = MediaSourceFs( + roots: [root.path], + cacheDir: cache.path, + settleTime: const Duration(milliseconds: 150), + ); + sessions = SessionManager( + identity: const CertificateFactory().generate(commonName: 'phone'), + info: const LocalDeviceInfo( + name: 'Phone', + platform: DevicePlatform.android, + role: DeviceRole.phone, + appVersion: 't', + ), + deviceStore: MemoryDeviceStore(), + preferredPort: 0, + ); + transfers = TransferEngine( + channels: sessions, + store: MemoryTransferStore(), + destination: (_, _) async => root.path, + ); + sessions.transfers = transfers; + final built = MediaServer( + source: source, + sessions: sessions, + transfers: transfers, + autoSendStore: store, + ); + await built.start(); + return built; + } + + setUp(() async { + root = await Directory.systemTemp.createTemp('pepo_auto_'); + cache = await Directory.systemTemp.createTemp('pepo_autoc_'); + store = MemoryAutoSendStore(); + }); + + tearDown(() async { + await server.stop(); + await transfers.dispose(); + await sessions.dispose(); + for (final d in [root, cache]) { + try { + await d.delete(recursive: true); + } catch (_) {} + } + }); + + test('a photo taken with the PC away waits in the queue', () async { + server = await buildServer(); + await server.setAutoSend(hubId, true); + expect(server.autoSendTo, {hubId}); + + await writeJpeg(root, 'IMG_0100.jpg'); + await pumpUntil(() => server.autoSend.pendingCount == 1); + expect( + server.autoSend.pendingCount, + 1, + reason: 'nothing is connected, so it has to be kept for later', + ); + }); + + test('the switch off means nothing is queued at all', () async { + server = await buildServer(); + await writeJpeg(root, 'IMG_0100.jpg'); + final change = source.changes.first; + await change.timeout(const Duration(seconds: 10)); + await Future.delayed(const Duration(milliseconds: 100)); + expect(server.autoSend.pendingCount, 0); + }); + + test('catch-up picks up what appeared while the process was down', () async { + // The queue remembers the switch was already on and how far it got. + final past = DateTime.now().toUtc().subtract(const Duration(hours: 1)); + await store.save(AutoSendData(enabledSince: past, catchUpAt: past)); + await writeJpeg(root, 'IMG_0001.jpg'); + await writeJpeg(root, 'IMG_0002.jpg'); + + server = await buildServer(); + await server.setAutoSend(hubId, true); + expect(server.autoSend.enabledSince, past, reason: 'the mark must not move'); + expect(server.autoSend.pendingCount, 2); + }); + + test('turning it on now does not push the whole camera roll', () async { + await writeJpeg(root, 'IMG_0001.jpg'); + await writeJpeg(root, 'IMG_0002.jpg'); + server = await buildServer(); + await server.setAutoSend(hubId, true); + expect(server.autoSend.pendingCount, 0); + }); + }); + group('MediaSourceFs', () { late Directory root; late Directory cache; diff --git a/packaging/linux/make-flatpak.sh b/packaging/linux/make-flatpak.sh index b902ac3..3382bed 100644 --- a/packaging/linux/make-flatpak.sh +++ b/packaging/linux/make-flatpak.sh @@ -32,7 +32,8 @@ finish-args: - --filesystem=xdg-download - --filesystem=xdg-pictures - --filesystem=xdg-videos - - --talk-name=org.freedesktop.Avahi + # Avahi lives on the system bus; the session name alone gets us nothing. + - --system-talk-name=org.freedesktop.Avahi - --talk-name=org.freedesktop.Notifications - --talk-name=org.kde.StatusNotifierWatcher modules: diff --git a/test/features/settings/phone_section_test.dart b/test/features/settings/phone_section_test.dart new file mode 100644 index 0000000..a59d1cd --- /dev/null +++ b/test/features/settings/phone_section_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pepo_core/pepo_core.dart'; +import 'package:pepoconnect/features/settings/sections/phone_section.dart'; +import 'package:pepoconnect/features/settings/settings_widgets.dart'; +import 'package:pepoconnect/platform/pepo_native.dart'; +import 'package:pepoconnect/shared/widgets/toggle_switch.dart'; +import 'package:pepoconnect/state/app_settings.dart'; + +import '../../ui/harness.dart'; +import '../pairing/test_support.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('org.pepoconnect/native'); + final messenger = binding.defaultBinaryMessenger; + Map status = const {}; + + final hub = viewOf( + fakePairedDevice(id: 'torre', name: 'Torre', platform: DevicePlatform.windows), + ); + + setUp(() { + status = const {'running': false, 'playing': false, 'held': false, 'refresh': 'available'}; + messenger.setMockMethodCallHandler( + channel, + (call) async => call.method == 'backgroundStatus' ? status : null, + ); + }); + + tearDown(() { + PepoNative.debugPlatform = null; + messenger.setMockMethodCallHandler(channel, null); + }); + + Future pumpSection( + WidgetTester tester, { + AppSettings settings = const AppSettings(animations: false), + List? devices, + }) async { + tester.view.physicalSize = const Size(900, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: featureOverrides(settings: settings, devices: devices ?? [hub]), + child: harness( + animations: false, + child: const SingleChildScrollView(child: PhoneSection()), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return ProviderScope.containerOf(tester.element(find.byType(PhoneSection))); + } + + /// Stops the 5 s status poll before the test ends. + Future close(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + + testWidgets('on iPhone the switch says what it really does and the state is shown', ( + tester, + ) async { + PepoNative.debugPlatform = 'ios'; + await pumpSection(tester); + + expect( + find.textContaining('Mantiene PepoConnect en marcha con la app cerrada'), + findsOneWidget, + ); + expect(find.text('Estado del segundo plano'), findsOneWidget); + expect(find.text('Parado'), findsOneWidget); + await close(tester); + }); + + testWidgets('a running engine shows how long the process has been alive', (tester) async { + PepoNative.debugPlatform = 'ios'; + status = { + 'running': true, + 'playing': true, + 'held': false, + 'refresh': 'available', + 'startedAt': DateTime.now().subtract(const Duration(minutes: 47)).millisecondsSinceEpoch, + }; + await pumpSection(tester); + + expect(find.text('Activo desde hace 47 min'), findsOneWidget); + await close(tester); + }); + + testWidgets('a session lost to another app reads as recovering', (tester) async { + PepoNative.debugPlatform = 'ios'; + status = const {'running': true, 'playing': false, 'held': false, 'refresh': 'available'}; + await pumpSection(tester); + + expect(find.text('Recuperándose…'), findsOneWidget); + await close(tester); + }); + + testWidgets('on Android the row is not there and the text is the service one', (tester) async { + PepoNative.debugPlatform = 'android'; + await pumpSection(tester); + + expect(find.text('Mantiene la conexión con el PC'), findsOneWidget); + expect(find.text('Estado del segundo plano'), findsNothing); + await close(tester); + }); + + testWidgets('the auto-send switch only writes the setting', (tester) async { + PepoNative.debugPlatform = 'android'; + final container = await pumpSection(tester); + final row = find.ancestor( + of: find.text('Enviar fotos nuevas automáticamente'), + matching: find.byType(SettingsRow), + ); + final toggle = find.descendant(of: row, matching: find.byType(ToggleSwitch)); + expect(tester.widget(toggle).value, isFalse); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + + final settings = container.read(settingsProvider); + expect(settings.autoSendPhotos, isTrue); + expect( + settings.defaultHubId, + hub.deviceId, + reason: 'the only paired PC becomes the hub, and AppServices applies it', + ); + await close(tester); + }); + + testWidgets('with no PC paired the switch stays off', (tester) async { + PepoNative.debugPlatform = 'android'; + final container = await pumpSection(tester, devices: const []); + final row = find.ancestor( + of: find.text('Enviar fotos nuevas automáticamente'), + matching: find.byType(SettingsRow), + ); + await tester.tap(find.descendant(of: row, matching: find.byType(ToggleSwitch))); + await tester.pumpAndSettle(); + + expect(container.read(settingsProvider).autoSendPhotos, isFalse); + await close(tester); + }); +} diff --git a/test/platform/bonjour_discovery_test.dart b/test/platform/bonjour_discovery_test.dart new file mode 100644 index 0000000..1c16b4d --- /dev/null +++ b/test/platform/bonjour_discovery_test.dart @@ -0,0 +1,104 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pepo_core/pepo_core.dart'; +import 'package:pepoconnect/platform/bonjour_discovery.dart'; + +const self = 'SELF0000000000000000000000'; +const peer = 'PEER0000000000000000000000'; + +BonsoirService service({ + String name = 'Torre', + int port = 47473, + List addresses = const ['192.168.1.20'], + Map? attributes, +}) => BonsoirService.ignoreNorms( + name: name, + type: pepoServiceType, + port: port, + hostAddresses: addresses, + attributes: + attributes ?? + const { + 'id': peer, + 'fp16': '0123456789abcdef', + 'name': 'Torre', + 'platform': 'win', + 'role': 'hub', + }, +); + +void main() { + group('candidateFromService', () { + test('a resolved PepoConnect service becomes a candidate', () { + final candidate = candidateFromService(service(), selfId: self)!; + expect(candidate.deviceId, peer); + expect(candidate.shortFingerprint, '0123456789abcdef'); + expect(candidate.name, 'Torre'); + expect(candidate.addresses, ['192.168.1.20']); + expect(candidate.port, 47473); + expect(candidate.platform, DevicePlatform.windows); + expect(candidate.role, DeviceRole.hub); + expect(candidate.source, DiscoverySource.mdns); + }); + + test('this very device is ignored', () { + final own = service(attributes: const {'id': self}); + expect(candidateFromService(own, selfId: self), isNull); + }); + + test('something else on the network is ignored', () { + expect(candidateFromService(service(attributes: const {}), selfId: self), isNull); + expect( + candidateFromService(service(attributes: const {'id': 'short'}), selfId: self), + isNull, + ); + }); + + test('a service that has not resolved yet is not a candidate', () { + expect(candidateFromService(service(addresses: const []), selfId: self), isNull); + expect(candidateFromService(service(port: 0), selfId: self), isNull); + }); + + test('the mDNS name is the fallback when the TXT record has none', () { + final noName = service( + name: 'MacBook de Ana', + attributes: const {'id': peer, 'platform': 'mac'}, + ); + final candidate = candidateFromService(noName, selfId: self)!; + expect(candidate.name, 'MacBook de Ana'); + expect(candidate.shortFingerprint, ''); + }); + }); + + group('usableAddresses', () { + test('drops what cannot be dialled and puts IPv4 first', () { + final out = usableAddresses([ + 'fe80::1c2d:3e4f:5a6b:7c8d', + '169.254.10.5', + '127.0.0.1', + '::1', + '2001:db8::5', + '192.168.1.20', + ]); + expect(out, ['192.168.1.20', '2001:db8::5']); + }); + + test('keeps every distinct address once', () { + expect(usableAddresses(['10.0.0.2', '10.0.0.2', ' ', '10.0.0.3']), ['10.0.0.2', '10.0.0.3']); + }); + }); + + group('BonjourDiscovery', () { + test('probe and stop do nothing before it has been started', () async { + final discovery = BonjourDiscovery(); + await expectLater(discovery.probe(), completes); + await expectLater(discovery.stop(), completes); + expect(discovery.isRunning, isFalse); + }); + + test('it announces the service iOS declares in Info.plist', () { + expect(pepoServiceType, '_pepoconnect._tcp'); + expect(BonjourDiscovery().serviceType, pepoServiceType); + }); + }); +} diff --git a/test/platform/ios_background_test.dart b/test/platform/ios_background_test.dart new file mode 100644 index 0000000..8794fe0 --- /dev/null +++ b/test/platform/ios_background_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pepoconnect/platform/ios_background.dart'; +import 'package:pepoconnect/platform/pepo_native.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('org.pepoconnect/native'); + final messenger = binding.defaultBinaryMessenger; + late List calls; + Map status = const {}; + + setUp(() { + calls = []; + status = const {'running': true, 'playing': true, 'held': false, 'refresh': 'available'}; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'keepAliveStart' => true, + 'backgroundHold' => (call.arguments as Map)['on'] as bool, + 'backgroundStatus' => status, + _ => null, + }; + }); + }); + + tearDown(() { + PepoNative.debugPlatform = null; + PepoNative.onBackgroundTask = null; + messenger.setMockMethodCallHandler(channel, null); + }); + + group('off iOS', () { + test('Android never hears about the keep-alive engine', () async { + PepoNative.debugPlatform = 'android'; + expect(IosBackground.isSupported, isFalse); + expect(await IosBackground.start(), isFalse); + await IosBackground.stop(); + await IosBackground.hold(true); + expect((await IosBackground.status()).running, isFalse); + expect(calls, isEmpty, reason: 'the Android service is what holds it up there'); + }); + + test('the desktop is untouched as well', () async { + PepoNative.debugPlatform = 'windows'; + expect(IosBackground.isSupported, isFalse); + await IosBackground.start(); + expect(calls, isEmpty); + }); + }); + + group('on iOS', () { + setUp(() => PepoNative.debugPlatform = 'ios'); + + test('start and stop drive the silent loop', () async { + expect(await IosBackground.start(), isTrue); + await IosBackground.stop(); + expect(calls.map((c) => c.method), ['keepAliveStart', 'keepAliveStop']); + }); + + test('a transfer in flight holds the process up and then lets go', () async { + await IosBackground.hold(true); + await IosBackground.hold(false); + expect(calls.map((c) => c.method), ['backgroundHold', 'backgroundHold']); + expect(calls.first.arguments, {'on': true}); + expect(calls.last.arguments, {'on': false}); + }); + + test('the state comes back with the uptime the phone can show', () async { + final started = DateTime(2026, 9, 18, 21, 30); + status = { + 'running': true, + 'playing': true, + 'held': true, + 'refresh': 'available', + 'startedAt': started.millisecondsSinceEpoch, + }; + final state = await IosBackground.status(); + expect(state.healthy, isTrue); + expect(state.held, isTrue); + expect(state.refresh, 'available'); + expect(state.startedAt, started); + }); + + test('running without playing is not healthy', () async { + status = const {'running': true, 'playing': false, 'held': false, 'refresh': 'denied'}; + final state = await IosBackground.status(); + expect(state.running, isTrue); + expect(state.healthy, isFalse, reason: 'the session was taken away'); + }); + + test('a native side that answers nothing degrades to off', () async { + messenger.setMockMethodCallHandler(channel, (call) async => null); + final state = await IosBackground.status(); + expect(state.running, isFalse); + expect(state.startedAt, isNull); + }); + + test('a background task reaches Dart and is answered', () async { + final seen = []; + PepoNative.onBackgroundTask = (id) async => seen.add(id); + PepoNative.init(); + await messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('backgroundTask', {'id': 'org.pepoconnect.app.sync'}), + ), + (_) {}, + ); + expect(seen, ['org.pepoconnect.app.sync']); + + await PepoNative.backgroundTaskDone('org.pepoconnect.app.sync'); + expect(calls.last.method, 'backgroundTaskDone'); + expect(calls.last.arguments, {'id': 'org.pepoconnect.app.sync', 'ok': true}); + }); + + test('a task arriving with nobody listening is not an error', () async { + PepoNative.onBackgroundTask = null; + PepoNative.init(); + await expectLater( + messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('backgroundTask', {'id': 'x'}), + ), + (_) {}, + ), + completes, + ); + }); + }); +}