Commit Graph

26 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
agra
a3020baeb9 camera: bump CameraX 1.3.4 → 1.4.2 for 16-KB page alignment
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.
2026-05-13 17:34:40 +03:00
agra
5cd3505272 camera: Android photo + preview + lifecycle (Phase 4a)
CameraX-backed Android implementation matching the iOS plugin's
surface (ux/camera + ux/camera/events), photo-capable only. Video
recording lands in Phase 4b (VideoCapture<Recorder>).

Modules in android/src/main/kotlin/io/swipelab/ux/camera/:
  CustomLifecycleOwner     drives ProcessCameraProvider's bindings
                           STARTED ↔ DESTROYED per instance
  DeviceOrientationBridge  OrientationEventListener → Surface.ROTATION_*
                           with 22.5° hysteresis; flutterToSurfaceRotation
                           + surfaceRotationToFlutter encode/decode the
                           four-quadrant wire enum the iOS plugin uses
  PreviewSink              CameraX Preview.SurfaceProvider →
                           SurfaceTexture → FlutterTexture (stable
                           textureId across resolution renegotiations)
  PhotoCapture             ImageCapture wrapper, per-shot
                           setTargetRotation, JPEG to cache dir
  CameraInstance           per-controller state: lifecycle owner,
                           texture, ProcessCameraProvider binding,
                           photo + preview use-cases, lens swap
  CameraPlugin             channel + permission flow: camera always,
                           mic optional (matches iOS' "camera denial
                           is the only hard failure" model)

UxPlugin.kt registers CameraPlugin alongside the other plugins.

Channel parity with iOS:
  availableCameras, create, initialize, dispose, setDescription,
  setFlashMode, lockCaptureOrientation/unlock (no-op; preview is
  pinned portrait), takePicture, audioPermissionStatus, openSettings.

startVideoRecording / stopVideoRecording return `unsupported_format`
until Phase 4b. Camera-device contention via lensesInUse + audio
claim via audioInUse mirror iOS's tracking, including the
setDescription swap (remove old lens / insert new) that closed the
device_busy leak on iOS.

Android APK builds clean against compileSdk 34, CameraX 1.3.4.
2026-05-13 17:25:28 +03:00
agra
0d64009f19 scanner: unbind CameraX on PlatformView dispose (Android)
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.
2026-05-13 10:05:59 +03:00
agra
1d00f16122 log: HttpSink + native crash capture (iOS/Android)
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.
2026-05-11 12:07:26 +03:00
agra
77cda5a17e gallery: library-change Stream + native observer parity
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.
2026-05-11 09:52:17 +03:00
agra
3eba30358c ... 2026-05-10 16:37:23 +03:00
agra
fb00e98681 clipboard: add UxClipboard.readImage native bridge
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".
2026-05-09 07:29:14 +03:00
agra
cc28782119 files 2026-05-07 12:56:00 +03:00
agra
26cdf63afc scanner 2026-05-07 09:22:01 +03:00
agra
6d8efafaa0 ... 2026-05-05 23:37:34 +03:00
agra
afc7e9c872 file: add path-only pick + native video thumbnail
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).
2026-05-02 13:14:21 +03:00
agra
7e0b9a6330 files 2026-04-22 22:42:53 +03:00
agra
2113537078 orientation 2026-04-22 16:22:45 +03:00
agra
b8d77e0a3a keyboard focus + app_info 2026-04-21 16:54:56 +03:00
agra
0be198e388 android: keyboard height tracking via JNI/FFI bridge
- C bridge (keyboard_bridge.c) stores keyboard state in globals.
  Kotlin writes via JNI, Dart reads via FFI — zero async delay,
  same architecture as iOS.
- WindowInsetsAnimation.Callback tracks open/close per-frame.
- OnGlobalLayoutListener catches silent height changes (emoji
  keyboard resize, floating keyboard toggle).
- Dart animation replay stays iOS-only; Android reads native
  per-frame values directly.
- Cleaned up old Java stub, updated build.gradle for Kotlin + CMake
  with 16KB page alignment (Android 15+).
- Example app rewritten to demonstrate UxKeyboard usage.
2026-04-15 23:49:16 +03:00
agrapine
810d060d44 red pill 2019-08-07 15:37:44 +01:00