The Mirror darwin/{Camera,Video} script is intentionally always-run so
darwin/ edits land in the next build without a manual pod install
(reason already in the prepare_command comment block). Xcode warns when
a script phase declares no outputs; opt out of its dependency analysis
via :always_out_of_date instead of enumerating outputs we don't actually
want to gate on.
The window-focus signal had no business living on the notifications
primitive — it was there because the same NotificationsPlugin happened
to observe NSApplication active/resign for its own reasons. Splitting
it into a sibling XWindow primitive (with its own WindowPlugin on
macOS, ux/window/events) lets future consumers — paused video,
deferred-work scheduling, dock badge counts — read focus state without
pulling in UNUserNotificationCenter.
XNotifications now only exposes notification I/O (show/cancel + tap +
authorization). The 'type:focus' event-channel branch is gone.
Used during the desktop-tap-doesn't-highlight investigation; root cause
turned out to be in the app's router-traversal logic, not the plugin.
Strip the noise from the production logs.
Helps diagnose why .list-style entries aren't landing in Notification
Center on macOS — logs willPresent firing and the add() result so we can
distinguish 'delivery never happened' from 'system filtered the entry'.
Returning [.alert, .sound] from willPresent shows the banner but never
adds the entry to Notification Center — .alert has been a banner-only
shim since macOS 11. On 11+ we need [.banner, .list] for the entry to
stick after the banner auto-dismisses.
Generic OS-notification + window-focus surface. Hand-rolled MethodChannel
+ EventChannel, registered through XPlugin alongside the existing camera
/ video / clipboard plugins. macOS native handler uses
UNUserNotificationCenter with thread-grouping support, NSApp activation
on tap, and NSApplicationDidBecomeActive/DidResignActive for the focus
signal (more reliable than Flutter's AppLifecycleState on macOS).
- video_player: ExoPlayer (Android) / AVPlayer (iOS/macOS) backend with
PixelBufferSink, method-channel adapter, Dart-side XVideoPlayer +
testing fake.
- insets: XInsets singleton + XAnimatedInsets widget lerp the system
viewPadding over 220ms so OS bar visibility toggles
(immersiveSticky <-> edgeToEdge) slide bottom-/top-anchored UI into
place instead of snapping by the nav-bar / status-bar height.
Catch-all commit for outstanding pre-existing local changes. Mixes
several themes that would normally be split:
- Rename: UxPlugin → XPlugin across iOS, macOS, Android registrants.
- New top-level packages under lib/src/: anim/ (animated values,
panes, sheets, dock, measured), core/ (Emitter, ReactiveBuilder
scaffolding, presenter/widget/value/dispose primitives), navi/
(Screen/ScreenStack/Router/hero/transitions), reactive/.
- Edits across existing plugins (clipboard, crash, file, gallery,
keyboard, scanner, sensor, url) to align with the new core.
- Test updates and CHANGELOG/README touches accompanying the above.
AOSP's `Patterns.PHONE` matches any run of 3+ digits, so short codes,
ZIP codes, version strings, sport scores, room numbers — anything
numeric — surfaced as tappable `tel:` links on Android. iOS
`NSDataDetector` is locale-aware and quieter but can still emit short
matches in promotional text.
Filter both shims at the conventional 7-digit minimum: NANP local is 7
digits, all international numbers run 7+. Below that the match is
almost certainly not a dialable number. On Android `canonical_phone`
returns an empty string and `run_pattern` drops the record; on iOS the
detector block bails early before emitting the match.
Fixes user reports of `88773` and `75309` (a famous 7-digit run minus
its area code) being incorrectly flagged.
Sync FFI from Dart into platform detectors:
- iOS / macOS: NSDataDetector(.link | .phoneNumber) + a tight bare-domain
pass that requires `/` or `?` (so `etc.` / `v1.2.3` don't false-positive
while `example.com/path` does match). NFKD-fold the phone capture so
full-width / Arabic-Indic digits collapse to ASCII; stop the digit run
at the first letter so `+1 555 1234 ext.99` doesn't fuse the extension.
- Android: JNI into android.util.Patterns (WEB_URL / EMAIL_ADDRESS / PHONE)
via a cached JavaVM, std::call_once for init, full per-call
ExceptionCheck coverage. UTF-16→UTF-8 conversion is hand-rolled to dodge
the Modified-UTF-8 / CESU-8 incompatibility with Dart's utf8.decode.
`UxUrl.launch(url)` is the matching tap action. Channel side dispatches via
UIApplication / NSWorkspace / Intent.ACTION_VIEW. Dart-side gates the URL
against a scheme allowlist (http, https, mailto, tel, sms, banlu, tg),
rejects bidi-override controls (U+202A..E / U+2066..9) to prevent visual
spoofs, and blocks USSD / MMI tel: codes containing `*` or `#`.
Library/native cleanup along the way:
- Renamed libux_keyboard.so to libux.so (also covers sensor + url).
- Collapsed three near-identical FFI loader stanzas across keyboard / sensor
/ url into a shared lib/src/_ffi.dart with `uxLib` + typed `uxLookupX`
helpers.
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.
Diagnostic from a fresh build with NSLog confirmed the rotation
behaviour on macOS: with `videoRotationAngle = 90`
(`.portrait`), `AVCapturePhoto.cgImageRepresentation()` returned a
720x1280 CGImage — *physical* portrait pixels, not just an EXIF
tag. So Apple's AVCaptureSession.h docs claiming
"AVCapturePhotoOutput uses EXIF only" don't hold on macOS. The
data-output connection on macOS also honors the same setter, which
is why preview + video flipped to rotated as soon as the photo fix
landed.
Pin macOS to 0° rotation (`.landscapeRight`):
- Photo: 1280x720 (sensor-native landscape, matches what the user
sees on screen).
- Preview: 1280x720 (matches the new desktop 4:3 page aspect).
- Video: 1280x720 (was already correct before the .portrait
change; back to that state).
The snapshot orientation argument is still ignored on macOS —
desktop cameras don't rotate. The argument carries through for iOS
where the camera page passes the device orientation.
`prepare_command` runs only on `pod install`. Mid-iteration Swift
edits to `darwin/Camera/*.swift` would never reach the built binary
until the user ran pod install or cleaned the Pods dir — confusing
during debugging (NSLog/diag additions silently absent from the
running app).
Add a `script_phases` build phase to both podspecs that rsyncs
darwin/Camera into Classes/Camera-shared before each compile. The
existing `prepare_command` stays as the install-time primer that
gives CocoaPods the initial file set to glob; the build phase keeps
contents fresh on every Swift edit thereafter. Verified: the resulting
binary now contains the NSLog strings that the prior build was
missing.
(Adding new files to darwin/Camera/ still requires pod install so
CocoaPods' source_files glob picks them up — script_phases only
refreshes content of files CocoaPods already knows about.)
`.landscapeRight` / `videoRotationAngle = 0` both still produced a
JPEG rotated 90° CW on macOS. User verified that `.portrait` /
`videoRotationAngle = 90` is the value that DOES NOT rotate the
captured frame on the photo output's connection — counter to the
iOS convention where `.portrait` rotates the landscape sensor
frame into portrait pixels.
I'd expect this to be macOS-specific photo-pipeline plumbing; the
data output's connection still doesn't honor the setter (isSupported
returns false or the setter no-ops), so preview + video stay
unaffected. Verified empirically; not chasing the AVFoundation
source for the why.
Setting `videoOrientation = .landscapeRight` had no effect on
macOS 14+ — Apple deprecated it in favour of `videoRotationAngle`
(a `CGFloat` in degrees) and the old setter is silently ignored
in newer versions. The captured JPEG stayed rotated 90° CW even
with our previous fix.
Try `videoRotationAngle = 0` first (macOS 14+) — that's "no
rotation from the sensor's natural orientation", which is landscape
on desktop cameras. Fall back to `videoOrientation = .landscapeRight`
for macOS 13 and older.
Same `applyUxCaptureOrientation` entry point — no caller changes.
iOS extension untouched; iOS still uses the per-snapshot
`videoOrientation` set (deprecated on iOS 17+ too, but still
functions there).
`AVCaptureVideoDataOutput`'s connection on macOS doesn't honor
`videoOrientation` (or its `isVideoOrientationSupported` is false) —
which is why the preview + recorded video were landscape and looked
fine even with our previously-no-op extension. `AVCapturePhotoOutput`'s
connection on macOS *does* honor it, and its default is `.portrait`
— same as iOS — so leaving it untouched rotated the captured JPEG 90°
CW relative to the landscape sensor.
The extension now sets `.landscapeRight` unconditionally (guarded by
`isVideoOrientationSupported`, so on the data output it's a no-op):
photo connection pins to landscape, JPEG EXIF orientation = 1 (no
rotation), captured image matches the preview.
Video + preview already correct → unaffected.
macOS preview was stretching (aspect wrong) and macOS photo capture
was rotating the landscape sensor 90° because the shared
PhotoOutput / CameraInstance code was setting
`AVCaptureConnection.videoOrientation` from the orientation snapshot
unconditionally. iOS needs that to rotate sample buffers to portrait;
macOS desktop cams are physically landscape and any rotation just
skews the result.
Moved the rotation call behind a per-platform extension on
`AVCaptureConnection`:
- `ios/Classes/Camera/AVCaptureConnection+iOS.swift` applies the
snapshot orientation (current behavior).
- `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a
no-op. macOS-flavoured photos / preview frames now flow at
native landscape orientation.
`CaptureDevice` reports sensorOrientation=0 on macOS (was hardcoded
90 for iOS); on macOS the page's `normalizeCameraCapture` math then
collapses to identity and the saved JPEG stays the landscape the
sensor produced. iOS keeps sensorOrientation=90 (matches
camera_avfoundation's reported value and the existing capture-
transform math).
Photo and video paths now both produce upright content on macOS
(video already worked because VideoRecorder's transform table maps
the always-portraitUp macOS snapshot to `.identity`).
Reuse the AVFoundation Swift files between iOS and macOS without
sprinkling `#if canImport(UIKit)` through them. The split is:
darwin/Camera/ platform-shared (AVFoundation only)
CameraPlugin channel + instance map
CameraInstance session + outputs + texture
CameraSession AVCaptureSession + runtime-error obs
CaptureDevice front/back discovery
PhotoOutput AVCapturePhotoOutput
PreviewSink CVPixelBuffer → FlutterTexture
VideoRecorder AVAssetWriter
DeviceOrientation wire-string enum
ios/Classes/Camera/ iOS-only impls + extensions
AudioSession AVAudioSession.upgradeForRecording
DeviceOrientationBridge UIDevice.orientation listener
CameraSession+iOS AVCaptureSessionWasInterrupted obs
+ InterruptionReason decode + the
application-audio-session flags
(all iOS-only on AVCaptureSession)
CameraSettings UIApplication.openSettingsURLString
FlutterRegistrar+iOS method-form of textures/messenger
macos/Classes/Camera/ macOS no-op stubs (same surface)
AudioSession no-op (no AVAudioSession on macOS)
DeviceOrientationBridge no-op (desktops don't rotate)
CameraSession+macOS no-op setupPlatform()
CameraSettings NSWorkspace → System Settings'
Privacy_Camera pane
FlutterRegistrar+macOS property-form of textures/messenger
`CameraSession.init` now calls `setupPlatform()` which each platform
provides via an extension — keeps the iOS-only interruption observer
and the `automaticallyConfiguresApplicationAudioSession` /
`usesApplicationAudioSession` flags (both iOS-only on AVCaptureSession)
out of the shared file. Flash-mode in PhotoOutput uses
`if #available(macOS 11/13, *)` rather than `#if`, since those are
plain version gates not platform splits.
The shared files compile into the iOS pod from `ios/Classes/Camera-shared/`
and into the macOS pod from `macos/Classes/Camera-shared/`, each a
mirror populated by a `prepare_command` in the podspec:
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
Symlinks and `../` source globs both fail — Pathname.glob bails on
symlinks, and CocoaPods silently drops paths that escape the pod
directory. The mirror destinations are .gitignore'd.
macOS UxPlugin now registers CameraPlugin alongside the others.
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).