camera: iOS implementation (Phase 2+3)

Native plugin owning AVCaptureSession + AVAssetWriter, mirroring
telegram-iOS's Camera module decomposition. Photo + video capture with
the writer-track transform set from a per-call orientation snapshot
(the three-way preview/capture/device split that camera_avfoundation
can't give us).

Modules:
  CameraPlugin           channels + per-handle instance map
  CameraInstance         session + texture + outputs + recorder
  CameraSession          AVCaptureSession + runtime-error/interrupt obs
  CaptureDevice          front/back discovery, per-device config
  PhotoOutput            AVCapturePhotoOutput, per-shot orientation
  VideoRecorder          AVAssetWriter, lazy inputs, pending-audio queue,
                         stop()/cancel() pair (matches telegram)
  PreviewSink            CVPixelBuffer → FlutterTexture
  AudioSession           setCategory + setActive(true) (only-widen)
  DeviceOrientationBridge

Recorder details:
  - Lazy videoInput/audioInput on first sample, sourceFormatHint:.
  - Audio settings derived from CMAudioFormatDescriptionGet*
    + recommendedAudioSettingsForAssetWriter, gated startWriting.
  - Stop sets stopSampleTime; next sample crossing it triggers
    maybeFinish → finishWriting. No watchdog — telegram pattern.
  - cancel() drops pending audio + cancelWriting + deletes file,
    used by CameraInstance.dispose when teardown finds in-flight
    recording.
  - Diagnostic stream → ux/camera/events {event: "diagnostic"}.

Dart surface extensions over Phase 1:
  - UxCameraValue.audioPermissionGranted
  - UxCameraController.refreshAudioPermission()
  - Static UxCameraController.audioPermissionGranted() /
    openSystemSettings()
  - UxCameraDiagnostic event variant
  - FakeUxCameraBackend.{emitDiagnostic, audioPermission,
    openSettingsCalls}

Tests: 32/32 in test/camera (controller + channel) green.
This commit is contained in:
agra
2026-05-13 16:56:49 +03:00
parent 45aac312a8
commit 6d6a871c53
18 changed files with 2337 additions and 22 deletions

View File

@@ -6,10 +6,13 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show Widget;
import '../file.dart' show UxFile;
import '../log.dart' show Log;
import '../sensor.dart' show UxSensor;
import 'camera_backend.dart';
import 'camera_preview.dart' show UxCameraPreview;
final _log = Log.tag('camera');
/// Describes a camera device on the system. Returned by
/// [uxAvailableCameras]; passed to [UxCameraController] to bind a
/// specific lens.
@@ -67,6 +70,7 @@ class UxCameraValue {
this.isRecordingVideo = false,
this.deviceOrientation = DeviceOrientation.portraitUp,
this.enableAudio = false,
this.audioPermissionGranted = false,
this.errorDescription,
});
@@ -91,6 +95,14 @@ class UxCameraValue {
final bool enableAudio;
/// True iff the user has granted microphone access. Updated when
/// the controller initialises and on
/// [UxCameraController.refreshAudioPermission]. Independent of
/// [enableAudio] — a controller can request audio (`enableAudio:
/// true`) without having permission, in which case recordings have
/// no audio track and callers should surface a hint.
final bool audioPermissionGranted;
/// Set to the last native session error's message when one fires.
/// Cleared on the next successful state transition.
final String? errorDescription;
@@ -104,6 +116,7 @@ class UxCameraValue {
bool? isRecordingVideo,
DeviceOrientation? deviceOrientation,
bool? enableAudio,
bool? audioPermissionGranted,
Object? errorDescription = _unset,
}) =>
UxCameraValue(
@@ -113,6 +126,7 @@ class UxCameraValue {
isRecordingVideo: isRecordingVideo ?? this.isRecordingVideo,
deviceOrientation: deviceOrientation ?? this.deviceOrientation,
enableAudio: enableAudio ?? this.enableAudio,
audioPermissionGranted: audioPermissionGranted ?? this.audioPermissionGranted,
errorDescription: identical(errorDescription, _unset)
? this.errorDescription
: errorDescription as String?,
@@ -177,24 +191,37 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
/// instance already holds the requested device or the audio session.
Future<void> initialize() async {
_throwIfDisposed('initialize');
final result = await UxCameraBackend.instance.create(
cameraId: description.id,
enableAudio: value.enableAudio,
preset: resolutionPreset,
);
_handle = result.handle;
_textureId = result.textureId;
_eventsSub = UxCameraBackend.instance.events(result.handle).listen(
_onEvent,
onError: (Object error, StackTrace? stack) {
value = value.copyWith(errorDescription: error.toString());
},
);
await UxCameraBackend.instance.initialize(result.handle);
value = value.copyWith(
isInitialized: true,
previewSize: result.previewSize,
);
try {
final result = await UxCameraBackend.instance.create(
cameraId: description.id,
enableAudio: value.enableAudio,
preset: resolutionPreset,
);
_handle = result.handle;
_textureId = result.textureId;
_eventsSub = UxCameraBackend.instance.events(result.handle).listen(
_onEvent,
onError: (Object error, StackTrace? stack) {
value = value.copyWith(errorDescription: error.toString());
},
);
await UxCameraBackend.instance.initialize(result.handle);
final audioGranted =
await UxCameraBackend.instance.audioPermissionGranted();
value = value.copyWith(
isInitialized: true,
previewSize: result.previewSize,
audioPermissionGranted: audioGranted,
);
} catch (_) {
// initialize failed mid-way. The native side may have allocated
// a handle + claimed the camera/audio. Tear down what we have
// so the next attempt isn't blocked by a leaked device claim.
// [_handle] / [_eventsSub] are cleaned by [dispose] which
// tolerates the partial state.
await dispose();
rethrow;
}
}
void _onEvent(UxCameraEvent event) {
@@ -205,8 +232,9 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
value = value.copyWith(errorDescription: description ?? code);
case UxCameraSessionInterrupted():
case UxCameraSessionResumed():
// Lifecycle pings; recovery is automatic on the native side.
break;
case UxCameraDiagnostic(:final message):
_log.i('recorder: $message');
}
}
@@ -278,6 +306,31 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
return file;
}
/// Re-poll the OS for mic permission state and update
/// [value.audioPermissionGranted]. Call on
/// `AppLifecycleState.resumed` to pick up grants made via Settings.
Future<void> refreshAudioPermission() async {
_throwIfDisposed('refreshAudioPermission');
final granted = await UxCameraBackend.instance.audioPermissionGranted();
if (granted != value.audioPermissionGranted) {
value = value.copyWith(audioPermissionGranted: granted);
}
}
/// Whether the user has granted mic permission to the app. Static
/// because the answer is global to the process — useful from UI that
/// needs the status before any controller has been created (e.g.
/// the camera page's "Tap to enable mic" banner).
static Future<bool> audioPermissionGranted() =>
UxCameraBackend.instance.audioPermissionGranted();
/// Deep-link into the system Settings page so the user can grant
/// mic permission. Static because it doesn't depend on any active
/// controller — useful from the banner tap before the controller
/// has finished initialising.
static Future<void> openSystemSettings() =>
UxCameraBackend.instance.openSettings();
/// Texture-backed widget that renders the live preview at its parent's
/// size. Hero-flightable.
Widget buildPreview() => UxCameraPreview(controller: this);

View File

@@ -85,6 +85,15 @@ abstract class UxCameraBackend {
/// controller subscribes during [initialize] and unsubscribes on
/// [disposeInstance].
Stream<UxCameraEvent> events(int handle);
/// True iff the user has granted microphone access. Cheap; safe to
/// re-poll on app foregrounding to detect grants made via Settings.
Future<bool> audioPermissionGranted();
/// Deep-link into the system Settings page for this app. Caller is
/// expected to refresh [audioPermissionGranted] on
/// `AppLifecycleState.resumed`.
Future<void> openSettings();
}
/// The tuple returned by [UxCameraBackend.create] — everything the
@@ -128,3 +137,11 @@ class UxCameraSessionInterrupted extends UxCameraEvent {
class UxCameraSessionResumed extends UxCameraEvent {
const UxCameraSessionResumed(super.handle);
}
/// Free-text diagnostic message from the native recorder. Routed by
/// the controller to `Log.tag('camera').i(...)` so it lands in the
/// log_server pipeline (`~/banlu/tools/log_server/data/banlu.jsonl`).
class UxCameraDiagnostic extends UxCameraEvent {
const UxCameraDiagnostic(super.handle, this.message);
final String message;
}

View File

@@ -131,6 +131,12 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
return UxFile(m['path'] as String);
}
@override
Future<bool> audioPermissionGranted() => _invoke<bool>('audioPermissionStatus');
@override
Future<void> openSettings() => _invokeVoid('openSettings');
@override
Stream<UxCameraEvent> events(int handle) {
return _rawEvents
@@ -162,6 +168,11 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
);
case 'sessionResumed':
return UxCameraSessionResumed(handle);
case 'diagnostic':
return UxCameraDiagnostic(
handle,
m['message'] as String? ?? '',
);
default:
return UxCameraSessionError(handle, 'unknown_event', null);
}

View File

@@ -60,6 +60,12 @@ class FakeUxCameraBackend implements UxCameraBackend {
UxCameraException? startVideoRecordingError;
UxCameraException? stopVideoRecordingError;
/// Audio permission state returned by [audioPermissionGranted].
/// Tests mutate this to drive the mic-permission UI banner.
bool audioPermission = true;
int audioPermissionCalls = 0;
int openSettingsCalls = 0;
// ---- internal ---------------------------------------------------
int _nextHandle = 1;
@@ -94,6 +100,10 @@ class FakeUxCameraBackend implements UxCameraBackend {
_controllerFor(handle).add(UxCameraSessionResumed(handle));
}
void emitDiagnostic(int handle, String message) {
_controllerFor(handle).add(UxCameraDiagnostic(handle, message));
}
// ---- UxCameraBackend impl --------------------------------------
@override
@@ -195,4 +205,15 @@ class FakeUxCameraBackend implements UxCameraBackend {
@override
Stream<UxCameraEvent> events(int handle) => _controllerFor(handle).stream;
@override
Future<bool> audioPermissionGranted() async {
audioPermissionCalls += 1;
return audioPermission;
}
@override
Future<void> openSettings() async {
openSettingsCalls += 1;
}
}