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

@@ -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.