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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user