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:
@@ -8,6 +8,9 @@ import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import io.flutter.view.TextureRegistry
|
||||
|
||||
/// One per `UxCameraController` on the Dart side. Owns CameraX's
|
||||
@@ -42,6 +45,44 @@ class CameraInstance(
|
||||
private var enableAudio: Boolean = false
|
||||
private var disposed = false
|
||||
|
||||
/// True iff `pauseForBackground` actually stopped a running
|
||||
/// lifecycle owner — `resumeForForeground` consults this so it
|
||||
/// doesn't restart instances the caller never initialised.
|
||||
private var wasRunningBeforePause = false
|
||||
|
||||
/// Observer attached to [ProcessLifecycleOwner.get] in [create]
|
||||
/// and removed in [dispose]. ON_STOP releases the camera so a
|
||||
/// backgrounded chat composer doesn't keep streaming; ON_START
|
||||
/// reacquires if we stopped it.
|
||||
private val processObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> pauseForBackground()
|
||||
Lifecycle.Event.ON_START -> resumeForForeground()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseForBackground() {
|
||||
if (disposed) return
|
||||
if (video.isRecording) video.cancel()
|
||||
// Move the per-instance lifecycle owner to CREATED so
|
||||
// CameraX releases the camera. STARTED → CREATED is
|
||||
// CameraX's "release-but-keep-bindings" transition.
|
||||
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
lifecycleOwner.stop()
|
||||
wasRunningBeforePause = true
|
||||
emit(mapOf("event" to "sessionInterrupted", "reason" to "appBackgrounded"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeForForeground() {
|
||||
if (disposed) return
|
||||
if (!wasRunningBeforePause) return
|
||||
wasRunningBeforePause = false
|
||||
lifecycleOwner.start()
|
||||
emit(mapOf("event" to "sessionResumed"))
|
||||
}
|
||||
|
||||
val textureId: Long get() = previewSink.textureId
|
||||
val previewSize: Size get() = previewSink.previewSize
|
||||
val currentLens: String get() = lens
|
||||
@@ -100,6 +141,15 @@ class CameraInstance(
|
||||
onReady(t)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
|
||||
// ProcessLifecycleOwner is the app-wide foreground/background
|
||||
// signal — independent of which Activity the engine is bound
|
||||
// to. Observer must be added on the main thread.
|
||||
ContextCompat.getMainExecutor(context).execute {
|
||||
if (!disposed) {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(processObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the lifecycle owner to STARTED — CameraX starts streaming
|
||||
@@ -114,6 +164,12 @@ class CameraInstance(
|
||||
fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
// Drop the app-lifecycle observer FIRST so a foreground/background
|
||||
// hop during teardown can't try to start/stop a half-disposed
|
||||
// session. Removal must be on the main thread.
|
||||
ContextCompat.getMainExecutor(context).execute {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(processObserver)
|
||||
}
|
||||
// Mirror iOS' dispose: any in-flight recording is hard-cancelled
|
||||
// (file discarded) rather than left to flush, since there's no
|
||||
// caller waiting on the result anymore.
|
||||
|
||||
Reference in New Issue
Block a user