CameraX 1.3.4 (May 2024) ships `libimage_processing_util_jni.so`
with 0x1000 (4 KB) ELF LOAD alignment. Android 15's 16-KB page
requirement rejects that — the user hit "elf alignment check failed"
on device. 1.4.0+ corrected the linker flags; 1.4.2 is the current
stable.
Also adds `camera-video` to the dep set so Phase 4b can use
`VideoCapture<Recorder>` without another bump.
Verified post-bump:
$ zipalign -v -c -p 16 app-release.apk → all lib/*/*.so (OK)
$ llvm-readelf -l libimage_processing_util_jni.so →
LOAD … 0x4000 (16 KB) on all four ABIs.
Drop isVideoMirrored on the AVCaptureVideoDataOutput connection — the
data output feeds both the preview texture AND the recorder, so any
mirror set there ended up baked into the recorded MP4. Recorded video
+ captured JPEG now carry the raw sensor feed ("as others see you"),
matching telegram-iOS and the stock iOS Camera app default.
The selfie preview is mirrored inside UxCameraPreview itself
(Transform.flip(flipX: true) around the Texture when
description.lens == front) — the analog of telegram's
CameraPreviewView.mirroring CALayer transform. Consumers
(CameraThumb, etc.) don't need to know which lens is active.
Two leak paths surfaced after a flip-then-record-then-pop session
left the front camera claim stranded:
1. setDescription swapped instance.device without telling the plugin —
devicesInUse still held the original cameraId. After dispose,
releaseClaim only removed the *current* id, leaving the original
stuck. Next push of the page hit device_busy on the original cam.
Fix: setDescription handler now does a contention check, inserts
the new id and drops the old (or rolls back on swap failure).
2. create's catch path called releaseClaim(for: instance), but if
configureSession threw before instance.device was set,
instance.currentCameraId is nil — and the cameraId we inserted on
line above leaked. Fix: drop the known cameraId + audio claim
explicitly in the catch.
Phase 1c+1d of the ux.camera plan (see ~/banlu/plans/ux_camera.md).
lib/src/camera/camera.dart — UxCameraController (ValueNotifier),
UxCameraValue, UxCameraDescription,
enums, UxCameraException,
uxAvailableCameras().
lib/src/camera/camera_backend.dart — abstract UxCameraBackend +
UxCameraCreateResult + sealed
UxCameraEvent variants.
lib/src/camera/camera_channel.dart — MethodChannelUxCameraBackend over
ux/camera + ux/camera/events. Per-
handle event demux. Maps
PlatformException → UxCameraException.
lib/src/camera/camera_preview.dart — UxCameraPreview: Texture-backed,
Hero-flightable preview widget.
lib/src/testing/fake_camera.dart — FakeUxCameraBackend with per-method
call lists + emitXxx event injection.
Exported from package:ux/testing.dart.
test/camera/camera_controller_test — 16 tests covering init/dispose,
orientation events, takePicture
(explicit + UxSensor fallback),
startVideoRecording / stop,
flip, flash, lock/unlock,
multi-instance, error propagation.
test/camera/camera_channel_test — 10 tests pinning the wire format
for every method + PlatformException
mapping.
Orientation snapshot for capture is computed Dart-side and passed in as an
explicit arg to takePicture / startVideoRecording (default falls back to
UxSensor.orientation at call time). Native never queries UIDevice itself
for the snapshot — Dart-side fakes drive orientation deterministically.
Native plugin code lands in Phase 2+; today every channel call throws
MissingPluginException at runtime, which is fine — the controller is only
mounted from the camera page once Phase 5 cuts over. The test backend
already exercises the full controller surface.
Frees the UxFile name for the value type. UxFile is now a minimal
{path} handle returned from anything in package:ux that produces a file
on disk (camera capture today, future writers). The existing
static-method namespace (pick/share/open/withScopedAccess/showInFolder/
videoThumbnail/supportsShowInFolder) becomes UxFiles. UxPickedFile is
unchanged.
Pairs with the banlu commit renaming the 5 app-side callers.
dispose() only shut down the analysis executor, leaving the camera
bound to the activity LifecycleOwner so the indicator stayed on after
the scan page popped. Hold the ProcessCameraProvider, unbindAll() in
dispose, and guard the async bind callback so we don't rebind after
dispose.
Apple's docs say the iOS 15+ presentLimitedLibraryPicker
completion handler runs on "an arbitrary serial dispatch queue".
Flutter method-channel results must be invoked on the main queue;
calling result(nil) from a background queue produces an iOS native
crash on dismiss. Wrap with DispatchQueue.main.async.
Three new pieces, all composable through the existing Log API
(`Log.configure(sink: ConsoleSink() + HttpSink(...))`) — no new
facade, no install side-effects.
HttpSink (lib/src/log_http.dart)
- Extends LogSink. Batches records and POSTs them as a JSON array
to a configurable endpoint with bearer auth.
- Defaults: batchSize=25, flushInterval=2s, queueCapacity=2000,
initialBackoff=1s capped at maxBackoff=30s.
- Drops oldest on queue overflow (single console warning).
- Retries 5xx and network errors with exponential backoff; drops
on 4xx with a single console warning.
- Pluggable `HttpSender` typedef for tests; default uses
dart:io.HttpClient.
CrashPlugin (ios/Classes/CrashPlugin.swift,
android/src/main/kotlin/.../CrashPlugin.kt)
- Installs uncaught-exception handlers
(NSSetUncaughtExceptionHandler / Thread.UncaughtExceptionHandler),
chains to the prior handler so the platform's default kill path
still runs.
- Writes one JSON file per crash to <cacheDir>/ux_crashes/<uuid>.json.
iOS captures NSException.name/reason/userInfo + call-stack symbols
and return addresses. Android captures thread name, exception
class, message, full stack (including cause chain).
- Caps the directory at 50 files; drops oldest by mtime on overflow.
- Exposes method channel `ux/crash` with drainPending / ackCrash /
triggerTestCrash. Registered in UxPlugin on both platforms.
UxCrash.drainAndReport (lib/src/crash.dart)
- Pulls persisted crash records on boot, re-emits each via Log.f
(tag `ux.crash`) so they flow out through whatever sink chain
the app installed, then acks each id.
- Tolerates MissingPluginException silently; PlatformException is
logged as a single warn without throwing.
Tests:
- log_http_test.dart: payload shape, batching, retry doubling on 5xx,
drop on 4xx, queue overflow ordering, non-encodable field
stringification, real loopback HTTP round-trip with the default
sender.
- log_http_e2e_test.dart: opt-in real-server round-trip gated by
--dart-define=E2E_LOG_ENDPOINT/E2E_LOG_TOKEN.
- crash_test.dart: drain + re-emit + ack across iOS and Android
shapes, MissingPluginException tolerance, PlatformException
warn-not-throw.
Swift `import PhotosUI` made the compiler happy but the binary never
referenced any PhotosUI symbol directly — `presentLimitedLibraryPicker`
is dispatched via `objc_msgSend` against an Obj-C category on
`PHPhotoLibrary`. Without an explicit framework link the linker
dropped PhotosUI, the category never registered, and iOS 26 raised
NSInvalidArgumentException for an "unrecognized selector".
Declare Photos + PhotosUI as podspec frameworks so the linker force-
loads them.
Add UxGallery.libraryChanges — a Stream<void> that emits whenever the
underlying photo library reports a change, so picker UIs can drop
their cached asset lists and reload reactively.
- iOS: GalleryPlugin conforms to PHPhotoLibraryChangeObserver +
FlutterStreamHandler; on photoLibraryDidChange the fetchCache is
cleared and a void event is pushed over ux/gallery/changes. The
observer is registered lazily on the first granted/limited
authorization so plugin init doesn't trigger iOS's permission
evaluation at app launch.
- macOS: near-verbatim port of the iOS shape (same Photos.framework,
same fetchCache staleness, same fix).
- Android: registers a MediaStore ContentObserver on
Files.getContentUri(VOLUME_EXTERNAL) with notifyForDescendants =
true; observer lifecycle tracks the Dart subscription (registered
on onListen, unregistered on onCancel / plugin detach). No native
cache to invalidate today, but the pipeline is wired for the
upcoming READ_MEDIA_VISUAL_USER_SELECTED limited-access work.
- iOS presentLimitedLibraryPicker switched to the iOS 15+ completion-
handler variant so the Dart await resolves on dismissal, not on
presentation. The actual reload is now driven by the change
observer (which fires after iOS commits the new subset),
side-stepping the completion-vs-commit race that produced an
off-by-one in the picker on consecutive MANAGE taps.
- FakeUxGalleryBackend exposes emitLibraryChange() so tests can
drive the reactive-reload wiring without going through the real
method channel.
Flutter's Clipboard API only exposes text shapes. Banlu's chat composer
needs image bytes from the system clipboard for desktop paste, so add
a UxClipboard facade backed by per-platform native plugins:
* iOS: prefer raw PNG/JPEG bytes off the pasteboard, fall back to
re-encoding `UIPasteboard.image` as PNG.
* macOS: prefer NSPasteboard `.png`, fall back to TIFF transcoded
through NSBitmapImageRep so screenshots / Preview hand-offs still
work.
* Android: read primary ClipData's first item URI and stream the bytes
through ContentResolver — don't trust the clip description's MIME,
copy whatever the resolver returns.
Returns null (never throws) when the clipboard has no image — callers
treat null as "fall through to text paste".
Two new methods on `UxFile`, both designed to keep large file content
out of the platform-channel buffer (the failure mode of file_selector
on Android: a ~200 MB video PUT through the Pigeon codec OOM'd the
JVM via `byte[size]` allocation in `FileSelectorApiImpl`).
`UxFile.pick({mimeTypes})` returns `UxPickedFile?` with `path`, `name`,
`mimeType`, `size`. The platform channel reply carries only the
metadata; bytes never cross.
- Android: `ACTION_OPEN_DOCUMENT` + `EXTRA_MIME_TYPES`, registered
as `ActivityResultListener`. On result, stream-copies the SAF
content URI to `cacheDir/ux_pick/<ts>_<safeName>` via an 8 KB
buffer (no full-file allocation in JVM heap), returns the cache
path.
- iOS: `UIDocumentPickerViewController(documentTypes:in: .import)`
— `.import` mode copies the picked file into the app's
Documents/Inbox so the URL is stable. Strong-retained delegate
(the picker's delegate ref is weak).
- macOS: `NSOpenPanel` with `allowedFileTypes`. Sheet-modal when a
Flutter window exists; free-modal otherwise.
`UxFile.videoThumbnail({path, atMs, maxWidth})` returns
`UxVideoThumbnail?` (PNG bytes + dims).
- Android: `MediaMetadataRetriever.getFrameAtTime(..., OPTION_CLOSEST_SYNC)`,
`Bitmap.createScaledBitmap` to maxWidth, PNG-encode via
`ByteArrayOutputStream`, recycle bitmaps in `finally`, release
retriever in `finally`.
- iOS: `AVAssetImageGenerator` with `appliesPreferredTrackTransform = true`,
`maximumSize = (maxWidth, 0)` (preserve aspect), ±500 ms tolerance
for keyframe alignment, decode on `userInitiated` queue.
- macOS: same generator, encoded via `NSBitmapImageRep`.
Compatible with the package's existing iOS 13 / macOS 10.15 deployment
targets — uses legacy `kUTType*` + `UTTypeCreatePreferredIdentifierForTag`
instead of `UTType` (iOS 14 / macOS 11).
Fix several race conditions in the iOS interactive dismiss flow:
- Don't skip keyboard close notifications during pan tracking, which
left Dart unaware the keyboard was dismissed
- Guard resignFirstResponder with generation check so reopened keyboards
aren't killed by stale dismiss completions
- Block pan tracking from starting during an in-flight dismiss animation
- Always reset keyboard view bounds when the keyboard opens, not just
when the dismiss animator is still running
- Handle duration=0 keyboard notifications by snapping immediately
- Gate adaptive learning debug output behind kDebugMode
Rewrite the example as a chat UI demonstrating ListenableBuilder,
scroll freeze during interactive dismiss, and proper bottom inset
handling with max(keyboardHeight, safeBottom).
Modernize example Android project from v1 to v2 embedding with
current AGP/Gradle versions.
- Dart-side animation replay using exact native curve (21-point lookup
table sampled from CADisplayLink) with 16ms start offset and 10ms
shorter duration to stay ahead of the native animation pipeline.
- Snap-back reads bounds presentation layer so height doesn't jump.
- Dismiss defers resignFirstResponder until bounds animation completes,
with immediate Dart close animation via animTarget/animGeneration.
- enableInteractiveDismiss accepts trackingInset to widen the gesture
zone above the keyboard (for composer height).
- FFI: anim_target, anim_duration, anim_gen, system_keyboard_height,
is_tracking exposed for Dart animation and scroll physics.