99 Commits

Author SHA1 Message Date
agra
2531fdcb74 FFmpeg software H.264 decoder: opt-in via pubspec flag
- Gate buildFfmpegJni + jniLibs packaging on `ux: enable_ffmpeg` in the
  consuming app's pubspec (default off) -- no LGPL / H.264-patent
  exposure unless explicitly enabled
- appInfoBuilder generates kUxEnableFfmpeg from the same flag so apps
  register the FFmpeg LGPL notice eagerly, pubspec-only (no dart-define)
- Add registerFfmpegLicense() + bundled LGPL-2.1 text asset
- FFmpeg compliance docs (LICENSES-3RDPARTY.md, android/ffmpeg/README.md)
- Network video streaming: XVideoPlayerController.network
2026-06-15 20:30:07 +03:00
agra
27cfc87def notifications + window: add Android native plugins
`ux/notifications/events` and `ux/window/events` only had macOS stream
handlers, so on Android/iOS the unconditional Dart subscription threw
MissingPluginException at startup (EventChannel reports activation
failures straight to FlutterError.onError, bypassing the `onError:`
callback).

- Gate each Dart event-channel subscription to platforms that register a
  native handler (`defaultTargetPlatform`), silencing iOS.
- `WindowPlugin`: report app foreground/background as host focus via
  `ProcessLifecycleOwner` ON_START/ON_STOP, so a backgrounded-but-alive
  process reports `focused = false`.
- `NotificationsPlugin`: local notifications (show/cancel by thread/all),
  POST_NOTIFICATIONS request on 13+, and tap routing back over the event
  channel — a tap that cold-starts the process is buffered until Dart
  subscribes.
- Regression tests for the subscription gate plus contract tests for the
  method/event payloads.
2026-05-30 13:39:49 +03:00
agra
e8f8882f2e navi: honor Screen.canPop across back/swipe pop paths
canPop was documented as gating the back button / swipe but was ignored by
ScreenBackHandler, XRouterBack.didPopRoute, and XRouter.canPop, so a stack
entry with canPop=false was still poppable by gesture or system back. Make it
authoritative in all three so a non-poppable pushed entry (e.g. an activation
screen) can't be backed out from under the user.
2026-05-29 20:05:37 +03:00
agra
2398c8ad35 insets: lazy _start capture matches frame-callback ts domain
`_start` was assigned from `currentSystemFrameTimeStamp` (raw,
since-boot) while the frame callback's `ts` parameter is
epoch-adjusted (since the binding's first observed frame). The two
live in different time domains; `dt = (ts - _start) / animDuration`
came out massively negative, `clamp(0, 1)` pinned `t = 0`, and
`_current` never moved past `_from` no matter how many frames fired.

The bug was masked on most flows because `_current` happened to
coincide with the post-animation target value already, but it
surfaced on Android EMUI 12 after dismissing a gallery from a
keyboard-open chat: the bottom inset stayed at 0 instead of
animating back to the nav-bar height, leaving the composer flush
with the screen edge and the system nav bar painted on top of it.
Opening the keyboard again forced an unrelated metric pump that
finally drove `_tick` to completion.

Fix: capture `_start` lazily from the first `_tick`'s `ts`, so both
sides of the subtraction live in the same epoch-adjusted domain.

Regression test asserts viewPadding advances past `_from` across
pumped frames; pre-fix it stayed at 0.
2026-05-29 08:40:19 +03:00
agra
4fa46725a9 keyboard: pre-R IME inset reports total bottom area to match iOS
On Android pre-R with a fixed nav bar stacked below the IME (e.g.
Huawei EMUI 12, 3-button nav), the prior formula
`systemWindowInsetBottom - stableInsetBottom` reported only the IME
proper and dropped the nav-bar strip. Consumers max-ing the value
against viewPadding.bottom under-shifted by exactly the nav-bar
height, so the composer's bottom edge ended up behind the IME's top
edge by ~stableInsetBottom.

iOS reports keyboard frames spanning all the way to the bottom of
the screen (covering the home-indicator area), so the cross-platform
consumer pattern `max(keyboard.height, viewPadding.bottom)` already
assumes that semantic. Pre-R now matches: when the IME is open,
publish the full `systemWindowInsetBottom` (nav bar + IME);
otherwise 0.

R+ branch untouched — `Type.ime().bottom` typically already includes
the nav-bar area on devices where the IME extends to the screen
edge. Left a TODO on the R+ path for HarmonyOS R+ with a fixed
nav bar; not currently testable.
2026-05-29 08:40:04 +03:00
agra
dc47fc0159 video: drain libavcodec's reorder buffer at end-of-stream
Closes H1 from the pre-ship review (the known-limit doc note added in
c0d55ba). The previous workaround was "first play-through truncates
the last ~16 frames; replay is fine because flush_buffers clears
libavcodec." That trade-off was OK for shipping but the proper fix
is to drain the reorder buffer before propagating EOS to ExoPlayer.

Media3's SimpleDecoder short-circuits the end-of-stream input buffer
and never invokes the subclass's decode(), so there's no hook to send
avcodec_send_packet(NULL). Every override worth overriding (decode
loop, queue methods, flush) is final on SimpleDecoder. So we
vendor a copy as FfmpegSimpleDecoder (Apache 2.0 attribution at the
top of the file) with one structural change: an EOS-drain state. On
EOS input, signalEndOfInput() flushes libavcodec's reorder queue,
then drainAtEndOfStream() is called on successive output buffers
until it reports DRAIN_DONE — at which point the loop attaches
BUFFER_FLAG_END_OF_STREAM and resumes normal teardown.

Everything else mirrors SimpleDecoder verbatim so upstream
improvements are cheap to pull forward.

  - FfmpegSimpleDecoder.java: vendored base class.
  - ffmpegVideoSignalEos JNI: sends avcodec_send_packet(NULL).
  - FfmpegVideoDecoder: extends the new base; signalEndOfInput
    forwards to the JNI; drainAtEndOfStream re-uses the existing
    ffmpegVideoReceiveFrame so per-frame PTS recovery and the
    pending_frame path from c0d55ba continue to work during drain.
2026-05-29 07:45:48 +03:00
agra
c0d55babf3 video: pre-ship review fixes for the FFmpeg renderer
Six prod-blocking issues and three correctness improvements from an
independent code review of 7243ef7. Verified on Huawei Mate 20 (EMUI
11) — playback, rotation, replay-after-end all still work.

  - EAGAIN on avcodec_send_packet was silently dropping the input
    packet (SimpleDecoder consumed it before we could retry).
    ffmpeg_jni.cc now caches a frame drained from the output queue
    into pending_frame, retries the send, and the next
    ffmpegVideoReceiveFrame emits the cached frame in order before
    pulling a new one.
  - C.TIME_UNSET == Long.MIN_VALUE == AV_NOPTS_VALUE was an
    undocumented coincidence between two upstreams. Gate it
    explicitly so a future Media3 sentinel change can't scramble
    display-order PTS recovery.
  - supportsFormat parses the H.264 profile from format.codecs and
    rejects non-8-bit profiles (High 10 / High 4:2:2 / High 4:4:4).
    These initialise libavcodec cleanly and only fail at the first
    receive — too late for ExoPlayer to fall through to MediaCodec.
    Rejecting upfront lets the platform decoder pick them up.
  - build_ffmpeg.sh wraps the whole run in a portable mkdir-based
    lock and clones into a staging dir + atomic rename with a
    sentinel file. Concurrent Gradle daemons no longer corrupt
    each other; an interrupted clone leaves no usable state for
    the next run to mistake as finished.
  - FfmpegOutputSurface and VideoCompositor both used to call
    eglTerminate(EGL_DEFAULT_DISPLAY) on teardown. That display is
    process-global and shared — the first teardown killed the
    other consumer's surface. Drop both calls; per-context cleanup
    + eglReleaseThread is sufficient. Likely cause of any "frozen
    surface after second video" report.
  - Rotation swap in renderOutputBuffer mutates the public
    outputBuffer.width/height. Bound it to SURFACE_YUV output mode
    via a currentOutputMode tracker; YUV-mode consumers
    (VideoDecoderOutputBufferRenderer.setOutputBuffer) read
    width/height expecting CODED dims that match yuvStrides[0] —
    the swap would walk chroma off the end of the allocation.
  - Fragment shader bumped from mediump to highp. The limited-range
    pre-scale (y - 16/255) * (255/219) was at risk of quantizing
    through 10-bit mediump and banding dark gradients on older
    Mali / Adreno parts. highp on the fragment is universally
    supported on GLES2 implementations Android ships post-2014.
  - Threading config comment was wrong about what FF_THREAD_SLICE
    does for H.264. Replace with the accurate explanation (slice
    threading degenerates to single-threaded on iOS's single-slice
    encodes; FRAME threading is rejected because of the input-side
    latency, not because libavcodec doesn't support it).
  - FfmpegVideoDecoder header documents two known limits the
    review surfaced but that don't have a clean fix at this layer:
    EOS tail-frame loss (~500 ms truncation on first play-through
    only; replay is fine because flush_buffers clears libavcodec)
    and the size-based colorspace heuristic mislabelling iPhone
    6/7-era unspecified-metadata BT.601 1080p clips as BT.709.
2026-05-29 07:33:20 +03:00
agra
7243ef7de4 video: vendor FFmpeg software AVC renderer
Adds an LGPL FFmpeg-backed video renderer that slots ahead of Media3's
MediaCodecVideoRenderer via EXTENSION_RENDERER_MODE_PREFER. Resolves
playback failures on Huawei EMUI 11 (Mate 20, Kirin 980): the Codec2
HiSilicon AVC decoder initialises cleanly on iOS High@3.1 streams with
deep DPB + full-range yuvj420p, then errors on the first sample inside
MediaCodecVideoRenderer (init-failure fallback can't catch this).
Google's C2 SW AVC decoder hits its 8-frame output-delay cap on the
same shape and stalls on dequeueOutputBuffer.

Media3's own decoder-ffmpeg ships only an audio renderer;
ExperimentalFfmpegVideoRenderer has been a stub since 2020 (returns
FORMAT_UNSUPPORTED_TYPE, createDecoder returns null). NextLib is
GPL-3.0. So we vendor our own Apache-licensed JNI on top of LGPL
FFmpeg, dynamically linked at runtime.

Build flow:
  - android/ffmpeg/ holds the JNI source + CMakeLists + orchestrator
    script + LGPL notice. No native binaries in git.
  - :ux:buildFfmpegJni Gradle task (wired to preBuild) clones
    Media3 1.9.2 + FFmpeg release/6.0 into build/ffmpeg-work/ on
    first run, builds h264-only static libs per ABI, links
    libffmpegJNI.so per ABI into build/jniLibs/<abi>/. AGP picks
    them up via sourceSets.main.jniLibs.srcDirs +=. Gradle
    UP-TO-DATE skips the task when ffmpeg_jni.cc / CMakeLists /
    build_ffmpeg.sh are unchanged.

Renderer:
  - FfmpegVideoDecoder (SimpleDecoder) sends each packet with its
    inputBuffer.timeUs as pkt->pts; the JNI overwrites
    outputBuffer.timeUs with f->pts on receive so frames emitted in
    display order carry their true display PTS (input PTS in decode
    order scrambles ExoPlayer's drop logic and halves the render
    rate on B-frame streams).
  - FfmpegOutputSurface does YUV->RGB in one GLES2 pass against an
    EGL window surface sized to display orientation. Y plane uses
    GL_NEAREST (1:1 sized, sampling at exact texel centres
    preserves luma detail); chroma uses GL_LINEAR. Pre-rotated quad
    UVs (0/90/180/270) keep the YUV sampling correct when the
    coded frame needs rotation for display.
  - FfmpegVideoRenderer swaps the output buffer's width/height for
    90/270 streams before super.renderOutputBuffer notifies size,
    matching MediaCodecVideoRenderer's post-rotation reporting.

Decoder fallback:
  - Renderers.kt selects FfmpegVideoRenderer first when
    libffmpegJNI.so is loaded; falls through to the platform path
    for formats FFmpeg doesn't handle or ABIs without the .so.
  - MediaCodec selector deprioritises every HiSilicon decoder
    (OMX.hisi.* and c2.hisi.*) so the platform path picks
    c2.android.avc.decoder ahead of the C2 Hisi variant when FFmpeg
    isn't available. Required because the C2 Hisi failure is
    post-init, which Media3's setEnableDecoderFallback(true) can't
    intercept.

Compositor:
  - VideoCompositor.setInputSurfaceSize lets the renderer resize the
    codec-input SurfaceTexture before eglCreateWindowSurface so the
    EGL surface inherits matching buffer dimensions on creation
    (MediaCodec sizes natively; EGL doesn't).
  - VideoPlayerInstance wires Renderers.build with a sizer callback
    that calls into compositor.setInputSurfaceSize from the FFmpeg
    renderer thread.

Adds docs/architecture.md with the layered video pipeline diagram,
file map, renderer-selection rationale, build flow, and LGPL
boundary notes.
2026-05-28 19:24:17 +03:00
agra
7ad3a38d38 podspec: silence 'will be run during every build' warning on Mirror script
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.
2026-05-27 15:34:34 +03:00
agra
76621a4132 window: extract XWindow primitive; XNotifications stops carrying focus
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.
2026-05-27 14:42:39 +03:00
agra
c3e540599a notifications: remove diagnostic NSLogs
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.
2026-05-27 13:58:52 +03:00
agra
c7ba8af498 notifications: add NSLog diagnostics + explicit .active interruption level
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'.
2026-05-27 13:09:50 +03:00
agra
68b97ce100 notifications: persist macOS toasts in Notification Center
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.
2026-05-27 13:05:49 +03:00
agra
f5d32a828f notifications: add XNotifications + macOS UNUserNotificationCenter plugin
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).
2026-05-27 13:02:51 +03:00
agra
ff520be971 insets: explicit-intent setSystemUiMode replaces always-on lerp
Animating every metric change baked in a boot race: XInsets._() ran
during App.production() (before runApp), and on Android cold starts
the activity hadn't been laid out yet — view.viewPadding read zero.
The next didChangeMetrics then lerped 0 → real over 220ms, leaving
the safe-area collapsed for the first ~13 frames. Visible as content
under the status bar + home indicator on launch.

- didChangeMetrics now snaps by default. _from / _start / _ticking
  state only spins up when a caller explicitly opted in.
- XInsets.setSystemUiMode(mode, {animate}) wraps
  SystemChrome.setEnabledSystemUIMode. The opt-in flag and the
  trigger live together — no caller-side ordering risk, no
  one-shot side-channel callers can misuse.
- Removed the public `duration` knob (tests no longer need to pin
  it; nothing animates without an explicit setSystemUiMode call).
2026-05-25 23:04:51 +03:00
agra
de4925adf9 video_player + insets: native playback backend + animated viewPadding
- 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.
2026-05-23 15:57:15 +03:00
agra
96df891b9d keyboard: clamp Dart curve to native height; narrow close-edge flush
The Dart-side predictive curve plays a sampled LUT to lead the system IME
animation by `_headStart`, so the composer can render where the IME will
be by the time the Flutter frame paints. When the LUT hasn't yet adapted
to the actual device curve, the prediction runs above the IME's real
position and the composer overshoots — quite visibly in debug mode where
Flutter's lower frame rate amplifies per-frame divergence, milder in
release where the rate is higher and learning converges faster.

Cap each per-frame `h` against the system-reported value (which is
per-vsync fresh on Android R+ via WindowInsetsAnimation.onProgress and
iOS via CADisplayLink). The clamp is a no-op on Android pre-R because
the native value snaps to target up-front, so the Huawei pre-R fallback
keeps animating via the LUT alone. Net effect on adapted devices: the
predictive lead is sacrificed for a per-frame "track the IME exactly"
guarantee — no overshoot regardless of LUT freshness.

Also narrow the previous-commit's `scheduleFrame` after notifyListeners
to fire only at the close-edge (`!isIOS && _height == 0`). Mid-animation
the steady-state pump or curveActive condition already schedules; only
the down-to-zero transition needs the explicit flush.
2026-05-22 21:26:55 +03:00
agra
de0a96b557 keyboard(android): pre-R IME tracking + flush dirty marks during persistent callback
KeyboardPlugin.setupInsetsCallback used to early-return on SDK < R, so
the FFI height stayed at 0 on API 29 devices like the Huawei Mate 20 Pro
— the chat composer never tracked the IME. Run the global-layout
listener on all SDKs, and on pre-R also wire setOnApplyWindowInsetsListener
since EMUI 10's IME-hide dispatches new insets without a follow-up layout
pass. Pre-R IME height comes from systemWindowInsetBottom −
stableInsetBottom (stable insets exclude things that animate in/out).

Inside XKeyboard._onFrame, follow notifyListeners with an explicit
scheduleFrame. _onFrame runs as a persistent frame callback after the
build phase has finished, so setState in listeners marks elements dirty
but ensureVisualUpdate is a no-op in this phase — the steady-state pump
masked the issue while the keyboard was open but on the close-edge
(h transitions to 0) the pump stops and the final rebuild was never
scheduled.
2026-05-22 20:55:07 +03:00
agra
34d3616d16 url: detect every scheme, supplementing native http/https/ftp/rtsp
AOSP Patterns.WEB_URL only matches http/https/ftp/rtsp, so chat-text
custom schemes (bl://, tg://, intent://) never reached Dart on Android.
NSDataDetector on iOS catches some but not exhaustively. Add a
Dart-side pass that scans for any RFC-3986 scheme://body and merges
into the native result set via greedy de-overlap, with trailing
sentence-end punctuation trimmed.
2026-05-22 14:35:36 +03:00
agra
3a2f6ef2e9 url: drop digit-bridge requirement, leading (/+/0 alone is enough
`0731098515` is a real local-format phone (trunk-prefix 0, no
separators) but the previous rule required a digit-separator-digit
transition and rejected it. The leading-char gate + whitespace
boundary already filter the bare-digit false positives we cared about
(`1778840642934`, `order-…`, `smoke-original-…`), so drop the bridge
check.
2026-05-22 12:40:33 +03:00
agra
54da3ef01f url: tighten phone detection to kill bare-digit false positives
Post-filter NSDataDetector / Patterns.PHONE results in Dart so a
candidate only links when (a) the char before it is whitespace, (b) it
starts with `(`, `+`, or `0`, and (c) either leads with `+` or has a
digit→separator→digit bridge. Bare runs like `1778840642934`,
identifier-glued spans like `order-1778840642934`, and version-style
strings no longer get flagged as phones.
2026-05-22 12:38:19 +03:00
agra
30a2933e7b gallery(android): Photo Picker Manage flow + per-type MediaStore queries
handleAssets queries MediaStore.Images.Media.EXTERNAL_CONTENT_URI and
MediaStore.Video.Media.EXTERNAL_CONTENT_URI separately and merges. The
previous unified MediaStore.Files query under-reported rows under
READ_MEDIA_VISUAL_USER_SELECTED — the Files URI requires the legacy
READ_EXTERNAL_STORAGE for full visibility, so limited-mode users saw
the wrong subset.

presentLimitedLibraryPicker on API 33+ now launches
MediaStore.ACTION_PICK_IMAGES (multi-select up to 100) instead of
re-calling requestPermissions. The latter falls back to the Photo
Picker UI when READ_MEDIA_IMAGES/VIDEO are USER_FIXED, but its Done
press doesn't write back to the USER_SELECTED grant; only an explicit
ACTION_PICK_IMAGES + onActivityResult path reliably grows the
accessible subset.

Selected URIs are pinned via takePersistableUriPermission and stored
in SharedPreferences (ordered, union semantics, capped at 400 with
oldest-first eviction). handleAssets folds them in as a third source
in Recents; onAttachedToEngine reconciles persisted entries against
ContentResolver.persistedUriPermissions so Settings-side revocations
between sessions are caught.

handleAlbums also drops unnamed buckets from the album dropdown —
they were rendering as blank rows; their photos remain reachable via
Recents.
2026-05-21 21:47:34 +03:00
agra
8fcb2b4af7 core: add Chrono — lifecycle-aware Timer (port from stated) 2026-05-21 17:17:30 +03:00
agra
05d408a50f ux: Pane.flushLayoutSync — synchronous layout pass for inline measurement
Subclasses that need a child's real measured size at gesture-time (not
the next frame) can set a per-class measure-request field and call this
to make `performLayout` run synchronously, observe the field, and
populate sizes. No extra frame scheduled.

Banlu's chat-stream uses this for scroll-to-reply on far-back history
that hasn't been laid out yet — without it, the path is either iterative
single-viewport jumps (waits for layout per jump) or minHeight estimates
that misalign the landing position.
2026-05-21 14:07:15 +03:00
agra
d68a2978eb ux: bulk WIP — UxPlugin→XPlugin rename + new anim/core/navi/reactive packages
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.
2026-05-21 08:58:07 +03:00
agra
a508aca2bb camera: requestAudioPermission — in-app prompt first, settings on permanent denial
Banner-tap entry point that shows the system mic prompt when the OS
will still surface one, and deep-links to Settings only on permanent
denial. Fixes the fresh-install trap where the mic entry isn't in the
Privacy pane until requestAccess has fired at least once.

Android tracks the first-asked state in SharedPreferences because
shouldShowRequestPermissionRationale returns false in two
observationally identical states (never asked vs permanently denied).
The existing initialize() request path writes the flag too, so a
banner tap after a record-then-deny correctly routes to Settings.

Refactored Android pendingPermission into PendingPermission(primary,
kind, cb) so audio-only requests check RECORD_AUDIO results instead
of always checking CAMERA.
2026-05-21 08:50:39 +03:00
agra
1a7ce1ac1b url: let plain-digit phones reach the native detector
`_hasSigil` short-circuits before any FFI hop and used to require a
`.`, `:`, `@`, or `+` in the text. A phone number with no leading `+`
and no separators (`0731098515`) or with parens-and-space-only
separation (`(0731) 098 515`) never crossed into NSDataDetector /
android.util.Patterns and silently rendered as plain body text.

Track a digit counter while walking the sigil scan and trip after the
fifth digit — enough to plausibly be a phone but cheap enough that
`I'm 30 today` still skips FFI. The native min-7-digit filter (added in
5512acd) still handles ZIPs / scores / version strings, so the looser
sigil doesn't cost us false positives on the rendered side.
2026-05-15 00:22:44 +03:00
agra
5512acd540 url: require 7+ digits to flag a match as a phone number
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.
2026-05-15 00:16:25 +03:00
agra
edca5c88f5 url: strip invisible chars at decode + reject LRM/RLM at launch
NSDataDetector and android.util.Patterns occasionally drag invisible
chars inside a URL when the source text uses them as soft separators —
tab / LF / CR / NBSP / zero-width markers / BOM / word joiner. The OS
URL parser then refuses the result and the tap silently fails.

`_sanitizeUrl` strips that fixed set on the Dart side as matches come
out of `_decode`, so the persisted V17 spans only carry openable bytes.
Bidi controls (U+202A..E, U+2066..9) are left in — they're a spoofing
primitive, not noise, and `_isLaunchable` rejects them outright. LRM /
RLM (U+200E/F) are added to the same launch reject set so a legacy
persisted URL that escaped the strip can't reach the OS handler.
2026-05-15 00:00:46 +03:00
agra
b4b5ee58a9 feat: UxUrl — native URL / phone / email detection + tap launcher
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.
2026-05-14 22:59:25 +03:00
agra
3d36f17edf 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.
2026-05-13 21:43:50 +03:00
agra
71c84179a6 camera: drop NSLog scaffolding now that macOS rotation is settled
The two `NSLog("[ux.camera] …")` calls were debug instrumentation for
diagnosing the macOS photo rotation issue. The bug is fixed
(macOS pinned to 0° rotation, photo + preview + video all 1280x720
landscape), so the NSLog calls are now stderr noise on every shot.

Keep the per-shot `diag(photo: WxH …)` emit since it goes through
`ux.Log` (gated by level, lands in banlu.jsonl) — useful if rotation
ever regresses on a different camera / macOS version, and the cost is
one log line per photo capture.
2026-05-13 21:28:05 +03:00
agra
4c10604cb8 camera: pin macOS connections to 0° rotation (sensor-native landscape)
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.
2026-05-13 21:24:52 +03:00
agra
0b5618948a camera podspec: mirror darwin/Camera on every build, not just pod install
`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.)
2026-05-13 21:20:39 +03:00
agra
e03e698caa camera: NSLog fallback in photo delegate so we can see it fired
User reports macOS photo still rotated AND no `photo:` line in
banlu.jsonl — so either the delegate isn't firing at all or the
event-channel path is broken on the success branch.

Add `NSLog` at the top of `photoOutput(_:didFinishProcessingPhoto:error:)`
and on the CGImage path. NSLog lands in `flutter run` stderr +
macOS Console regardless of channel state, so even if the diag
event drops we'll see the delegate firing + the CGImage's
dimensions. Once we see them we'll know whether it's a delegate
problem or a channel problem.
2026-05-13 20:36:22 +03:00
agra
9ba8ff8e61 camera: macOS uses cgImageRepresentation + ImageIO (skip fileDataRepresentation EXIF)
Per Apple's AVCaptureSession.h docs (line 1106), AVCapturePhotoOutput
applies connection rotation via EXIF tags rather than physical pixel
rotation. The macOS variants of `fileDataRepresentationWithCustomizer`
and `fileDataRepresentationWithReplacementMetadata` are
API_UNAVAILABLE(macos), so we can't replace the embedded EXIF
through the standard customizer.

Workaround: on macOS, grab the raw `AVCapturePhoto.cgImageRepresentation()`
— Apple documents this as "the physical rotation of the CGImageRef
matches that of the main image. Exif orientation has not been
applied." — and re-encode the JPEG via `CGImageDestination` with no
orientation metadata. Resulting JPEG has sensor-native landscape
pixels and no EXIF Orientation tag; viewers and Flutter's image
codec both display as landscape.

iOS path unchanged (still uses fileDataRepresentation).

Diagnostic now fires on both the success and failure branches of
the delegate, so when the capture fails (e.g. the macOS
"OSStatus error 13" the user observed) the failure reason — domain,
code, localized description — is captured to banlu.jsonl instead
of silently dropped.
2026-05-13 20:32:14 +03:00
agra
cb2b57f661 camera: log captured JPEG dims + EXIF Orientation post-shot
Per Apple's macOS AVCaptureSession.h docs (line 1106), setting
`videoRotationAngle` on `AVCapturePhotoOutput`'s connection
"does not necessarily result in physical rotation of video buffers
… In the AVCapturePhotoOutput, orientation is handled using Exif
tags." So our connection-rotation tweaks only affect the EXIF
Orientation tag the JPEG carries — pixel data is sensor-native.

Yet the user keeps seeing a rotated JPEG even after `stripJpegApp1`
removes APP1 (EXIF). So either the pixel buffer IS rotated despite
the docs, or EXIF is in a non-APP1 marker, or Flutter's decoder
auto-rotates somehow.

Log the actual captured JPEG's dimensions + EXIF Orientation to
banlu.jsonl via the existing per-handle diagnostic stream:
`CGImageSourceCopyPropertiesAtIndex` reads
`kCGImagePropertyPixelWidth/Height/Orientation` from the JPEG
bytes that `AVCapturePhoto.fileDataRepresentation()` produces.

Format: `photo: WxH landscape|portrait exifOrientation=N`.

Once we see what AVCapturePhotoOutput is actually producing on the
user's Mac we'll know which side of the pipeline to fix.
2026-05-13 20:26:24 +03:00
agra
41c3fab7b5 camera: pin macOS photo connection to .portrait (counter-intuitive)
`.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.
2026-05-13 19:48:04 +03:00
agra
c5a1b50982 camera: use videoRotationAngle on macOS 14+ to pin photo to landscape
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).
2026-05-13 19:43:44 +03:00
agra
f0a7f0b3a1 camera: pin macOS photo connection to .landscapeRight
`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.
2026-05-13 19:40:51 +03:00
agra
8bed5435ad camera: stricter macOS dispose order + plugin diagnostics → ux.Log
Two changes that target the macOS "camera not found" leak after a
few open/close cycles. macOS's `AVCaptureDevice.DiscoverySession`
excludes devices that are still claimed by another session — even
our own zombie session that hasn't fully released its grip on the
hardware. So if dispose leaves the session in a partially torn-down
state, the next `availableCameras` returns empty.

CameraInstance.dispose now:
  - Cancels the recorder (was already there) so the audio
    data output's retain on the recorder drops.
  - Stops the session.
  - **Nils sample-buffer delegates** on the video + audio data
    outputs before removing them. `setSampleBufferDelegate` holds a
    strong reference to the delegate; the macOS reference to our
    `SampleFanout` was transitively keeping the session alive.
  - Removes inputs + outputs inside a single
    `session.configure { … }` block (begin/commitConfiguration) so
    AVFoundation sees the teardown as one atomic transition rather
    than a sequence of partial states. Apple's docs are explicit on
    this; we weren't following.
  - Clears the strong references to the instance vars.

Plugin diagnostics:
  - When availableCameras returns empty, native now emits an event
    `{handle: -1, event: "diagnostic", message: …}` carrying the
    current `devicesInUse`, `audioInUse` and `instances` keys.
    Per-handle diagnostics already flow through a controller's
    `_onEvent`; plugin-level ones (handle == -1) had no path to the
    log_server jsonl.
  - `MethodChannelUxCameraBackend` now subscribes to its raw event
    stream once and pipes any handle=-1 diagnostic through
    `ux.Log.tag('camera').i('plugin: …')`. The subscription kicks in
    when the broadcast stream is first accessed (still lazy —
    matches the prior behavior).

If the macOS "camera not found" reproduces, the jsonl will show
which side leaked: a non-empty `devicesInUse` says our claim
tracking is stale; an empty one says AVFoundation itself is
holding the hardware.
2026-05-13 19:35:36 +03:00
agra
de1a9fd25e camera: emit previewSize from the first sample buffer's real dims
`device.activeFormat.formatDescription` reports the device's
*selected* format, but on macOS the session-preset remap means the
data output sometimes delivers a different resolution than what
the active format claims. The mismatch surfaced as a stretched
preview on macOS: camera_thumb's SizedBox was sized for a 4:3
buffer, the FittedBox(cover) was given a 16:9 texture, so the
texture stretched to fill the wrongly-shaped box.

PreviewSink now snapshots `CVPixelBufferGet{Width,Height}` from
the first sample buffer that arrives and forwards it via a
callback. CameraInstance hooks the callback to emit the existing
`previewSizeChanged` event (same shape the Android backend uses).
The Dart controller writes it into `value.previewSize`,
camera_thumb's SizedBox snaps to the real buffer aspect, and
FittedBox(cover) crops cleanly without stretching.

Identical wire shape and event name as the Android equivalent —
no Dart changes needed.
2026-05-13 19:20:00 +03:00
agra
8ab672c12a camera: per-platform capture-orientation extension + macOS sensor=0
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`).
2026-05-13 19:07:29 +03:00
agra
a6d2539722 camera: macOS device discovery now includes external + Continuity
Discovery was hard-coded to `.builtInWideAngleCamera` only — that
catches the iOS front/back cameras and the macOS FaceTime HD on
macOS 14+, but missed USB webcams (`.external` on 14+,
`.externalUnknown` before) and iPhone-as-webcam Continuity Camera
(`.continuityCamera` on 14+). On Macs whose built-in camera doesn't
expose itself as `.builtInWideAngleCamera`, the result was "no
camera detected".

Device types are now platform-conditional: iOS keeps the wide-angle
filter as-is; macOS adds the externals + Continuity (gated via
`if #available(macOS 14.0, *)` for the post-14 forms vs the
deprecated `.externalUnknown` for older macOS). The `#if os(macOS)`
guard is unavoidable — `.external` and friends literally aren't
declared as enum cases on iOS.
2026-05-13 18:58:37 +03:00
agra
14565ebd7a camera: macOS port via darwin/ split (no shared-file pragmas)
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.
2026-05-13 18:53:46 +03:00
agra
16f986ab37 camera tests: cover async previewSize updates via PreviewSizeChanged
New controller test exercises the Android path where create returns
with the iOS-style synchronous previewSize and the event then
revises it once CameraX's SurfaceRequest fires with the negotiated
resolution. Asserts that previewRotationQuarterTurns stays untouched
(events don't carry rotation; rotation is fixed per camera).

33/33 tests in test/camera/ now green.
2026-05-13 18:24:59 +03:00
agra
c4a8eb634f camera: Android video recording (Phase 4b)
CameraX VideoCapture<Recorder> wired alongside Preview + ImageCapture
in a single bindToLifecycle call. Mirrors the iOS surface contract:

  startVideoRecording(handle, snapshotOrientation) → null
    Records to a UUID-named MP4 in cacheDir. Audio enabled iff the
    instance was created with enableAudio: true AND RECORD_AUDIO has
    been granted (no SecurityException if the user denied mic but
    asked for audio — recording proceeds silent). targetRotation set
    per-call so the file's rotation metadata matches how the device
    was held at recording start.

  stopVideoRecording(handle) → {path}
    Resolves when CameraX delivers the VideoRecordEvent.Finalize.

Telegram-fidelity mirror: VideoCapture.Builder.setMirrorMode(MIRROR_MODE_OFF)
overrides CameraX's default MIRROR_MODE_ON_FRONT_ONLY so selfie
videos record the raw sensor feed ("as others see you"). Preview-side
mirror stays a CameraX-managed SurfaceTexture transform; the recorded
file is independent.

Quality: Quality.HD (720p) — keeps file sizes reasonable for chat
composer use and well within mid-range Android devices' budget for
binding all three use cases (Preview + ImageCapture + VideoCapture).
Fallback for devices that reject the 3-use-case bind would be next
iteration.

instance.dispose() now hard-cancels any in-flight recording (drops
file, no caller waiting) — matches iOS' recorder.cancel() path.
2026-05-13 18:21:16 +03:00
agra
181fce6ab9 camera: don't double-mirror front cam on Android
CameraX's Preview use case builds the SurfaceTexture transform
matrix with a horizontal mirror baked in for the front camera (so
the selfie preview reads naturally without consumer effort). Flutter
applies that matrix when sampling the texture. Adding our own
`Transform.flip(flipX: true)` on top double-mirrors — which on its
own would just un-mirror the selfie, but combined with CameraX's
counter-rotation when the device tilts, it makes the rotation appear
to *follow* the phone (i.e. tilt CW 90° → preview goes 90° more
CW). Removing the Flutter flip lets CameraX's matrix do the work on
its own.

iOS keeps the Flutter flip: AVCaptureConnection.isVideoMirrored on
the data output is `false` there (capture-time mirror would land
in the recorded MP4), so the preview-only mirror must live in the
widget tree.
2026-05-13 18:14:26 +03:00
agra
f78dd4d846 camera: Android preview rotation stays at 0 (texture transform applies)
Flutter's `Texture` widget on Android samples the underlying
`SurfaceTexture` with its transform matrix applied (the GL
`GL_TEXTURE_EXTERNAL_OES` sampler reads
`SurfaceTexture.getTransformMatrix()` each frame), and CameraX
populates that matrix with the rotation needed for upright display.
A `RotatedBox` on top of that double-applies — manifested as the
preview being 90° CW from where it should be.

So `CameraInstance.previewRotationQuarterTurns` is hard-coded to 0
on Android. The wire field stays (iOS always emits 0 too because
AVCaptureConnection.videoOrientation pre-rotates the sample
buffers); future preview pipelines that bypass the SurfaceTexture
transform would set it to a non-zero value.
2026-05-13 17:52:33 +03:00
agra
cc243b7b0a camera: previewRotationQuarterTurns + async previewSize event
Black-screen + extra-90°-rotation on Android both came from
AVFoundation vs CameraX behaving differently at the preview output:

  - AVFoundation: data-output connection's `videoOrientation`
    pre-rotates sample buffers. The Flutter Texture displays them
    upright; `device.activeFormat` reports the sensor-native size
    synchronously.
  - CameraX: the SurfaceProvider hands back a Surface; CameraX
    writes raw sensor frames into it. Rotation is a *transform hint*
    via Preview.setTargetRotation that consumers must apply
    themselves. And the final negotiated resolution isn't known
    until the first SurfaceRequest fires — which happens AFTER
    bindToLifecycle, AFTER lifecycle.start, async on the camera
    executor. So `create` was returning Size(0,0).

Surface extension to bridge the gap:

  - UxCameraValue.previewRotationQuarterTurns (int 0/1/2/3).
    iOS native always emits 0; Android native emits
    `(sensorRotationDegrees / 90) % 4` for the active camera.
    [UxCameraPreview] wraps the Texture in a RotatedBox by that many
    quarter-turns (applied *before* the front-cam mirror so the
    flip lives in screen space, not sensor space).

  - UxCameraPreviewSizeChanged event. Android emits this from
    PreviewSink.onResize whenever a SurfaceRequest carries a new
    resolution; the controller copies it into value.previewSize.
    First emission is what unblocks the camera_thumb's SizedBox
    from its initial 0x0 = "render nothing" state.

  - UxCameraBackend.setDescription's return changed from `Size` to
    `({Size previewSize, int previewRotationQuarterTurns})` so
    a lens swap can both update the rotation and signal that a new
    previewSizeChanged event is incoming.

iOS continues to send previewSize in the create result (the active
format is known synchronously); no previewSizeChanged emission is
needed there. The new field is set to 0 in both create and
setDescription results on iOS.
2026-05-13 17:44:45 +03:00