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