camera: app-lifecycle pause / resume (Phase 6 polish)

Camera page kept the session running while the host app was
backgrounded — wastes battery, holds the hardware, and blocks
other apps from grabbing the camera. Add per-platform observers
that pause/resume the session on app foreground/background, with
a uniform `pauseForBackground` / `resumeForForeground` pair on the
shared CameraInstance.

Behaviour:
  - On background: any in-flight recording is hard-cancelled
    (matches every messaging app — the take ends with the app
    switch). The session stops so the OS can release the camera.
  - On foreground: session restarts iff it had been running.
    Emits `sessionInterrupted` (`reason: appBackgrounded`) and
    `sessionResumed` events so the Dart side can surface UX
    affordances if needed.

iOS — `ios/Classes/Camera/CameraInstance+iOS.swift`:
  Subscribes to UIApplication.{willResignActive, didBecomeActive}
  notifications. Work hops onto sessionQueue so AV mutations stay
  serialised. Storage uses the shared
  `CameraInstance.lifecycleCleanup` closure slot — extension
  doesn't need to add stored properties.

Android — added `androidx.lifecycle:lifecycle-process:2.7.0`,
  observes `ProcessLifecycleOwner.get().lifecycle`. ON_STOP →
  `pauseForBackground` (cancels recording + drops
  CustomLifecycleOwner to CREATED → CameraX releases camera).
  ON_START → `resumeForForeground`. Observer add/remove on main
  thread per `ProcessLifecycleOwner` contract.

macOS — `macos/Classes/Camera/CameraInstance+macOS.swift`:
  Intentional no-op. macOS desktop background semantics are
  softer; the chat composer's Card dialog typically stays
  foregrounded. Slot is wired so the shared
  `observeLifecycle()` call still compiles.

Verified: all four platforms (iOS / Android / macOS / app tests)
build clean. Pod install picks up the new iOS extension file
once Pods/ is fresh — `flutter clean` if mid-iteration.
This commit is contained in:
agra
2026-05-13 21:43:50 +03:00
parent 71c84179a6
commit 3d36f17edf
5 changed files with 178 additions and 0 deletions

View File

@@ -164,6 +164,56 @@ final class CameraInstance {
"orientation": next.rawValue,
])
}
observeLifecycle()
}
// MARK: - App lifecycle
/// Set to `true` by [pauseForBackground] when it stops a
/// running session, so [resumeForForeground] knows whether to
/// restart. Skipped when the session was already stopped
/// (e.g. dispose racing background).
private var wasRunningBeforePause = false
/// Cleanup closure installed by per-platform `observeLifecycle()`
/// undoes whatever observers it registered. Invoked from
/// [dispose] so the instance doesn't leak NotificationCenter /
/// `ProcessLifecycleOwner` subscriptions.
var lifecycleCleanup: (() -> Void)?
/// Called by the per-platform lifecycle observer when the host
/// app moves to background. Hard-cancels any in-flight recording
/// (matches every messaging app backgrounding ends the take)
/// and stops the session so the camera hardware is released.
/// Runs on sessionQueue.
func pauseForBackground() {
if disposed { return }
if let recorder = videoRecorder {
recorder.cancel()
videoRecorder = nil
fanout.recorder = nil
emit([
"event": "sessionInterrupted",
"reason": "appBackgrounded",
])
}
if session.av.isRunning {
session.stop()
wasRunningBeforePause = true
}
}
/// Called by the per-platform lifecycle observer when the host
/// app returns to foreground. Restarts the session only if
/// [pauseForBackground] stopped a running one. Runs on
/// sessionQueue.
func resumeForForeground() {
if disposed { return }
guard wasRunningBeforePause else { return }
wasRunningBeforePause = false
session.start()
emit(["event": "sessionResumed"])
}
/// Start the session. Must run on sessionQueue.
@@ -195,6 +245,12 @@ final class CameraInstance {
if disposed { return }
disposed = true
// Tear down the lifecycle observer FIRST so a notification
// arriving mid-dispose doesn't try to pause/resume a
// half-torn-down session.
lifecycleCleanup?()
lifecycleCleanup = nil
if let recorder = videoRecorder {
recorder.cancel()
videoRecorder = nil