react-native-attachment-kit 1.0.17 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +472 -22
- package/android/build.gradle +8 -3
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +60 -341
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +60 -341
- package/ios/RNAttachmentKit.swift +66 -771
- package/ios/RNAttachmentKit.xcframework/Info.plist +5 -5
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +6853 -669
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6853 -669
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6853 -669
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +22 -22
- package/package.json +1 -1
- package/src/types.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,50 +1,500 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 — 2026-10-27
|
|
4
|
+
|
|
5
|
+
- **New public API for native screens: `AttachmentProcessor` (iOS framework + Android aar)** — the "after picking" processing that used to live only inside the RN bridge (photo resize and save as webp / jpg / png, photo thumbnail, video export with `VideoExporter` and its poster, the processing-start slots with predicted sizes and posters, progress throttling, per-video cancel, temp files and the result model). An app's own native screen can now open the kit gallery and process the result with exactly the same code as `openGallery`, so both produce the same files (size, thumbnail, poster).
|
|
6
|
+
- iOS: `AttachmentProcessor.process(_:videoStates:options:job:onStart:onVideoProgress:onItem:)`, `startItem`, `processPhoto`, `processVideo`, and the helpers (`resizeImage`, `saveImage`, `encodeWebP`, `makePhotoThumbnail`, `photoThumbnailLayout`, `videoFrame`, `requestPoster`, `prepareCacheDirectory` …). Models `AttachmentProcessingOptions`, `AttachmentStartItem`, `AttachmentResult` (`.dictionary` = the RN result fields), `AttachmentProcessingJob` (`cancel(index:)` / `abort()`), `AttachmentProgressThrottle`; `PhotoThumbnailOptions` is now public.
|
|
7
|
+
- Android: `com.rnattachmentkit.AttachmentProcessor` (`process` / `processPhoto` / `processVideo` are `suspend`), `Options`, `Result` (`toMap()` = the RN result fields), `Listener`; `ProcessingJob.abort()` / `isAborted`.
|
|
8
|
+
- The RN bridge (`openGallery` on both platforms, iOS `openEditor` video / photo paths) now calls these — no change in the JS API, events or result fields. The progress HUD's cancel is `job.abort()` (whole send cancelled, Promise rejected as before).
|
|
9
|
+
- **Android: the aar is exposed to the app (`api`)**, together with `material` and `media3-transformer` (supertypes / signatures of public classes). An app's native code can use the kit classes without its own `compileOnly` copy of the aar. Class names are kept by the aar's `-keep class com.rnattachmentkit.** { *; }`; apps that minify keep the same rule in their `proguard-rules.pro` (HeyTalk / ZemTalk already do).
|
|
10
|
+
- **License: `LicenseManager.isInitialized` and `whenInitialized(timeout:)` (iOS + Android)** — lets a native screen wait for the RN side's `AttachmentKit.init` before it opens the gallery or processes anything (the gallery's video permission and the photo watermark read the license).
|
|
11
|
+
- Checked in HeyTalk (new devices `HeyTalk_KIT` simulator / emulator, one batch of a 4032×3024 jpg + a 1080p 6 s mp4): on iOS and on Android the RN `openGallery` path and the app's native chat room produced byte-identical photo webp (1024×768), processing-start poster and export poster, and mp4s with the same codec, size, duration, frame count, bitrate and byte count (only the mp4 creation time differs). An Android photo bug introduced while moving the code (bounds-only decode treated as failure, so every photo was dropped) was found by that check and fixed before this build. ZemTalk: iOS device build (no signing) and Android Kotlin/Java compile pass with no change to ZemTalk.
|
|
12
|
+
|
|
13
|
+
## 1.0.18 — 2026-10-23
|
|
14
|
+
|
|
15
|
+
- **Image viewer (iOS) and full-screen camera (iOS + Android): pull-down follows the finger back up freely**, like the editor. Before, dragging down and then back above the starting point made the photo / camera stop at a fixed height.
|
|
16
|
+
- iOS viewer: the "drag started upward" check looked at `view.transform`, which never moves (the photo is `scrollView`), so every upward move was skipped. It now checks `scrollView.transform`, the same rule as the editor (`collectionView.transform`).
|
|
17
|
+
- iOS camera: the vertical move was clamped with `max(0, translation.y)`; the camera now follows `translation.y` (scale like the editor). Fade, corner radius and the close decision still use only the downward distance.
|
|
18
|
+
- Android camera: `translationY` used the clamped pull; it now follows `dy * 0.7` up and down like `PullToDismissLayout` (editor / viewer). Scale and fades still use only the downward distance.
|
|
19
|
+
- iOS typecheck and Android module compile. Not tried on a device or simulator yet (the simulator was in use by another app).
|
|
20
|
+
|
|
21
|
+
- **Viewer: closing a video page cross-fades to the chat thumbnail while it flies back (iOS + Android)**. The close animation flies the current video frame back to the message; at the end the message's own thumbnail (the poster) appeared in one frame, which looked like a flash when the video had moved on from the first frame. Now the poster is laid over the frame inside the flying image, transparent, and fades in during the middle of the animation (15% → 75% of the close), so it arrives as the thumbnail. iOS: a poster `UIImageView` inside `ZoomTransitionAnimator`'s dummy view (poster from the page's image view, else the memory cache). Android: `dismissViewer` passes a `LayerDrawable` [frame, poster copy] to `playCloseAnimation` and raises the poster layer's alpha in the animator. Pull-down close and button close both use it; photo pages are unchanged.
|
|
22
|
+
- iOS framework build (simulator) and Android module compile. Not tried on a device yet.
|
|
23
|
+
|
|
24
|
+
- **Android full-screen camera: a second finger during pull-down no longer leaves the camera stuck mid-drag**. Touching with another finger while dragging the camera down turned the drag off (as if a pinch started) without putting the camera back, so after lifting the fingers it stayed shrunk where it was. While dragging, extra fingers are now ignored (no pinch zoom) and the camera keeps following the finger that started the drag; lifting that finger (or all fingers) finishes the drag as usual — closes if pulled down far enough, otherwise springs back. Drag / release code moved into `dragTo` / `finishDrag`. iOS was not affected (its pan takes one touch and ends on `.cancelled` too).
|
|
25
|
+
- Android module compile. Not tried on a device yet.
|
|
26
|
+
|
|
27
|
+
- **iOS viewer: no dark flash when a video starts playing, and it starts as soon as possible**. The poster → video fade (1.0.17) put a black background on the player layer as soon as `isReadyForDisplay` was true, but the layer can go blank for a frame or two right when playback actually begins; the black background then showed through the half-faded video and the poster dimmed for a moment (sometimes, depending on whether the first frame was ready before playback started). Now the poster always stays behind and the video fades in over it the moment the first frame is ready, with no background — a blank frame just shows the poster. Only once frames are clearly on screen (the play position moved about 0.05 s, or the player is paused, or 1.5 s passed) and the fade has finished (+0.2 s) does the black background come in (0.2 s) to hide the poster where its aspect differs from the video. Re-attaching the player that is already shown (back to the same page) no longer hides it and fades it in again.
|
|
28
|
+
- iOS framework build (simulator). Not tried on a device or simulator yet.
|
|
29
|
+
|
|
30
|
+
- **Viewer: `startWithUiHidden` option (iOS + Android overlay viewer)**. Opens the viewer with the top bar, bottom bar and video controls hidden — a tap shows them (and, while a video plays, they hide again after 3 s as before). While the UI is hidden the centre button still appears when the video is stopped (▶) or loading / downloading (spinner, ring), but not during the open animation before autoplay starts, and the loading spinner only after 0.4 s of loading (a cached file buffers for a moment and starts — the spinner used to pop up and fade out showing ⏸). When loading ends while the UI is hidden and the video plays, the button fades out without revealing ⏸. Android: dragging the viewer and letting go no longer brings hidden bars back to their place invisibly (their close / save buttons stayed tappable).
|
|
31
|
+
- **Viewer video controls stay up while a save / share / delete menu is open (iOS + Android)**. The controls auto-hid 3 s after playback started even while the "save all / this video" or "share all / this video" chooser, the delete confirmation or the share sheet was open, so the top bar slid away and took the chooser anchored to its button with it (iOS: the popover moved up under the status bar). The 3 s timer now waits while the viewer is in use — scrubbing, the speed menu, anything the viewer presents (iOS `presentedViewController`; Android: the viewer window has lost focus to a popup, dialog or the share chooser), or a save / share still fetching its items (download HUD) — and starts counting again once that ends (`isVideoUiInUse` / `waitUntilVideoUiIdle` on iOS, `isUiInUse` / `waitUntilUiIdle` on Android). Photo pages have no auto-hide, so nothing changes there.
|
|
32
|
+
|
|
33
|
+
## 1.0.17 — 2026-10-22
|
|
34
|
+
|
|
35
|
+
- **Viewer: a remote video is downloaded once, not twice (iOS + Android)**. The player streamed the video while a separate background download fetched the same file for the cache (`cacheFile`), so every first view used twice the data and the two downloads slowed each other down.
|
|
36
|
+
- Now only the background download fetches the file, writing to `<cacheFile>.part` as it arrives, and the player reads that growing file: it waits where the download has not arrived yet (the center ring / bar show it), and only a request far past the downloaded part (a `moov` at the end of the file, or a seek far ahead) is fetched separately with an HTTP range request. If the download fails, the player continues from where it is with range requests.
|
|
37
|
+
- Android: `ViewerStreamDataSource` (Media3 `DataSource` over `ViewerVideoDownload`), used through `ProgressiveMediaSource` when the page has a `cacheFile`. iOS: `ViewerStreamLoader` (`AVAssetResourceLoaderDelegate` on a custom-scheme `AVURLAsset`); `ViewerVideoFiles` now uses a data task that writes the `.part` itself (the download task's temp file could not be read while downloading).
|
|
38
|
+
- A download that ends short of `Content-Length` now counts as failed (it used to be saved as complete).
|
|
39
|
+
- Removed in 1.0.15's "switch to the downloaded file if it finished before playback started" — no longer needed.
|
|
40
|
+
- **Viewer: the progress bar can only be dragged over the downloaded part while a video is downloading (iOS + Android)**. Seeking into a part that had not arrived yet left the player waiting there with no feedback. The thumb now stops at the downloaded fraction (2% inside, since bytes ≈ time); once downloaded, or for local / non-cached videos, there is no limit.
|
|
41
|
+
- **iOS: the video fades in over the poster after the viewer opens (no flash)**. The video layer used to replace the poster in one frame on the first decoded frame — most visible with HDR videos (iPhone HLG), which iOS shows with HDR brightness, so the picture jumped brighter than the SDR poster. The video still plays as the original (HDR stays HDR); only the switch is smooth: the player layer stays hidden until the first frame is ready, then fades in over 0.35 s. The open animation is unchanged (playback still starts in `viewDidAppear`).
|
|
42
|
+
- **Viewer video options pill: playback-speed icon, first on the left (iOS + Android)**. The ⚙ button (it only opened the playback-speed menu) now shows a speed icon (iOS `speedometer`, Android `ic_viewer_speed` — Material "speed") and is the leftmost button: speed · mute · (HDR) · fullscreen.
|
|
43
|
+
- **iOS viewer: HDR on/off button for HDR videos**. Shown in the options pill only when the video track is HDR (`containsHDRVideo` or HLG / PQ transfer function). On (default) plays the original HDR; off plays it with SDR (BT.709) brightness through a `videoComposition`. The choice applies right away and is remembered (`UserDefaults` `RNAttachmentKit.viewerVideoHDR`). The video stays hidden behind the poster until this is decided, so an "off" video never flashes HDR. If the SDR composition makes playback fail (seen on the iOS Simulator; on macOS the same composition plays and toggles fine), the viewer drops it, continues the original from the same position and hides the button for the rest of the app run (`ViewerVideoHDR.compositionUnsupported`). Switching while playing used to flash and jump back to the previous key frame (changing the composition re-prepares the player); now the current frame is laid over the video (keeping its old brightness), playback continues from exactly the same time (zero-tolerance seek) and the overlay fades out over 0.35 s once the new frames are on screen (`switchVideoHDR`). Android has no button (its TextureView already shows HDR videos at SDR brightness).
|
|
44
|
+
- **Viewer video controls don't hide while you use them (iOS; Android touch-up)**. iOS: the controls auto-hid 3 s after playback started even while the playback-speed menu was open or the pill buttons were being tapped. Every touch on a pill button now restarts the 3 s timer (`ViewerVideoControlsView.onInteraction`), and nothing hides while the speed menu is open (`ViewerMenuButton` reports menu open/close; the timer restarts when it closes). Android already kept the controls while its speed menu was open; opening the menu now also restarts the timer.
|
|
45
|
+
- **Video poster = first frame of the sent video (iOS + Android)**. iOS `onProcessingStart` used the gallery thumbnail (the Photos key frame, not always the first frame) when the video was not trimmed; it now always reads the frame at the (trimmed) start. Export thumbnails are taken exactly at 0 s (iOS: zero tolerance instead of the nearest key frame around 0.1 s; Android: `OPTION_CLOSEST` instead of `OPTION_CLOSEST_SYNC`, which also affected the trimmed-start poster).
|
|
46
|
+
|
|
47
|
+
## 1.0.16 — 2026-10-22
|
|
48
|
+
|
|
49
|
+
_Not published on its own — shipped in 1.0.17._
|
|
50
|
+
|
|
51
|
+
- **Viewer: downloaded part on the video progress bar, like YouTube (iOS + Android)**. Behind the played part the bar shows, slightly lighter, how far the video is available: the larger of what the player has buffered and how much of the file the background download has saved (full once downloaded or when the video is a local file). Android `SeekBar.secondaryProgress`; iOS `ViewerScrubSlider` now draws its own track (full / buffered / played) with `bufferedFraction`, `ViewerVideoControlsView.setBuffered`.
|
|
52
|
+
- **Viewer: no second download of the same video (iOS + Android)**. Background downloads were matched by URL; signed URLs change on every open, so reopening a video while it was still downloading started the same file again (and wrote the same `.part`). Downloads are now matched by their destination file, so a reopen joins the running download (and its progress).
|
|
53
|
+
|
|
54
|
+
## 1.0.15 — 2026-10-22
|
|
55
|
+
|
|
56
|
+
- **Viewer: download progress ring on remote videos (iOS + Android)**. A remote video page streams and, when `cacheFile` is given, downloads the file in the background; the center button only showed a spinner while it buffered.
|
|
57
|
+
- While the video is loading and the background download is running, a ring on the edge of the center button fills clockwise from 12 o'clock with the downloaded fraction (faint track behind it; the spinner stays in the middle). Hidden once playback starts; shown again if it buffers while the download is still running. Android `ViewerDownloadRing`, iOS `ViewerPlayButton.setDownloadProgress`.
|
|
58
|
+
- If the download finishes before streaming ever started playing, the page switches to the downloaded file and plays it (no more waiting on a stream that needs the whole file).
|
|
59
|
+
|
|
3
60
|
## 1.0.14 — 2026-10-21
|
|
4
61
|
|
|
5
|
-
- No code changes from 1.0.13.
|
|
62
|
+
- No code changes from 1.0.13. First release published automatically by GitHub Actions (npm trusted publishing on a push to the Dist repo `develop`, `.github/workflows/publish.yml`).
|
|
6
63
|
|
|
7
64
|
## 1.0.13 — 2026-10-21
|
|
8
65
|
|
|
9
|
-
- iOS:
|
|
66
|
+
- **iOS: crash closing the picker — `-[AVCaptureSession stopRunning]` exception (Crashlytics, `closure #1 in CameraPreviewView.stopPreview()`)**.
|
|
67
|
+
- Cause: the gallery camera does all session work (start, stop, recording formats) on one serial queue, but the full-screen camera's front/back flip changed the session on its own queue (`com.overlay.camera.q`). If the picker closed while a flip was inside `beginConfiguration … commitConfiguration`, `stopPreview` called `stopRunning` mid-configuration and AVFoundation threw.
|
|
68
|
+
- The flip now runs on `CameraPreviewView.sharedSessionQueue` (made internal; the separate queue is gone), so every start / stop / configuration of the session is serialized. No other code touches the session outside that queue.
|
|
69
|
+
- Typecheck only (the simulator has no camera to reproduce the race).
|
|
10
70
|
|
|
11
71
|
## 1.0.12 — 2026-10-20
|
|
12
72
|
|
|
13
|
-
- Album picker ("최근 항목 ⌄")
|
|
73
|
+
- **Album picker ("최근 항목 ⌄") no longer crashes on fast taps or on first use (iOS + Android)**.
|
|
74
|
+
- iOS: only one album popup can be pending at a time (`isAlbumPickerPending`); repeated taps while albums were still loading started several wait loops, the second `present` failed and the never-shown popup crashed on release. `AlbumPickerViewController.deinit` now touches its lazy table / background views only if the view was loaded (creating them while deallocating crashed in `objc_storeWeak`). The popup waits for any other presented screen / transition and stops if the gallery closed. On first use, albums are read only once photo access is granted (before, an empty "loaded" list made the popup wait 2 s and open empty).
|
|
75
|
+
- Android: `AlbumPickerDialog` is not shown twice and not after the fragment state was saved (`IllegalStateException`).
|
|
76
|
+
- Typecheck (iOS) and module compile (Android).
|
|
14
77
|
|
|
15
78
|
## 1.0.11 — 2026-10-20
|
|
16
79
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- Android:
|
|
80
|
+
- **Android: gallery camera cell "Tap to allow camera" is localized** — was a hard-coded English string in `item_image_grid.xml`; now `@string/camera_tap_to_allow` (59 languages, from `scripts/i18n/strings.json`).
|
|
81
|
+
- **Viewer: `showDeleteButton` option (iOS + Android)** — hides only the trash icon in the viewer top bar (default `true`, save/share stay). Use it where the app does not handle `onImageDeleted` (e.g. another user's profile photo), so the viewer does not look like it can delete someone else's photo.
|
|
82
|
+
- **Android: picker asks for video permission once** — on Android 14+, when "selected photos" access (`READ_MEDIA_VISUAL_USER_SELECTED`) was already granted, the picker opened right away and never asked for `READ_MEDIA_VIDEO`, so a user who first granted photo access (e.g. a profile photo at sign-up) could never see videos. It now asks once when videos are requested and video access was never asked (remembered in `rnak_media_perm`); after that it opens right away again, even if video was denied.
|
|
20
83
|
|
|
21
84
|
## 1.0.10 — 2026-10-19
|
|
22
85
|
|
|
23
|
-
- iOS: photos just saved from another app (e.g. KakaoTalk)
|
|
86
|
+
- **iOS: photos just saved from another app (e.g. KakaoTalk) show at the top of "최근 항목"**. The gallery sorted Recents by capture date (`creationDate`). A photo saved from KakaoTalk keeps its original EXIF date, so right after saving it went to that date's place (days or years back) instead of the top and looked missing, while the Photos app showed it at the top.
|
|
87
|
+
- Recents now uses the order the photos were added, like the Photos app's Recents and the Android picker (`DATE_ADDED`). Public API only: the Recents smart album (`smartAlbumUserLibrary`) is fetched without sort descriptors (album order = added order, oldest first) and read from the end (`PhotoManager.recentsByAddedOrder`, used by the first batch and the full list). Other albums keep capture-date order. The album picker's Recents cover is the newest added photo.
|
|
88
|
+
- Reproduced and checked on the simulator: a JPEG with EXIF date 2023-05-01 added via `simctl addmedia` appeared far down before the change and at the top after; a second one (EXIF 2025) added later is above it; the rest of the list continues normally.
|
|
89
|
+
- The date indicator while scrolling still shows each photo's capture date.
|
|
24
90
|
|
|
25
91
|
## 1.0.9 — 2026-10-19
|
|
26
92
|
|
|
27
|
-
- **Location, video and AI need a license key
|
|
28
|
-
-
|
|
29
|
-
-
|
|
93
|
+
- **Location, video and AI need a license key (iOS + Android)**. Without a valid `init()` key (evaluation) only photos work: picking, editing (watermark) and viewing.
|
|
94
|
+
- UI stays visible and explains: tapping the Location tab, the ✦ AI generate button, the editor AI tool or the "AI 이미지" tab shows a localized "License required / This feature requires a license key." notice (new strings `license_required_title` / `license_required_message`, 59 languages), and the attach menu stays on the previous tab.
|
|
95
|
+
- Gallery `mediaType: 'video' | 'all'` shows photos only; holding the camera shutter shows the notice instead of recording (and does not take a photo).
|
|
96
|
+
- RN APIs reject with `E_LICENSE_REQUIRED`: `openMapViewer`, `renderLocationThumbnail`, `reverseGeocode`, `openViewer` with any `videos` entry, `openMediaGallery` / `appendGalleryItems` with video items. `setAIConfig` is still accepted (logs a warning) so the AI buttons appear and show the notice.
|
|
97
|
+
- New `LicenseGate` (iOS `LicenseManager.swift`, Android `LicenseManager.kt`). iOS: the gate sits in `GalleryBaseViewController.mediaType`, the tab handler, `aiGenerateTapped`, the editor AI tool, the camera long press and the bridge; the menu tab is reverted on the next run loop (the iOS 26 tab bar applies the selection after the callback). Android: `ImagePickerBottomSheet` (mediaType, tabs, ✦), `CameraExpandOverlay` (long press), `ImageEditorActivity` (AI tool) and both RN module copies (newarch / oldarch).
|
|
98
|
+
- Test apps init their own app-ID key: iOS harness (`-noLicense` launch argument = evaluation), Android test app (`--ez noLicense true` = evaluation).
|
|
99
|
+
- README: license table.
|
|
100
|
+
- Checked: iOS simulator (evaluation: photos only, Location tab and ✦ show the notice and the tab returns to Gallery; licensed: videos and Location work), S23 test app (same). Android module compiled, iOS framework built and bridge typechecked. The Android RN module copies are not compiled here (they build with the app); the camera long-press notice was not tried by hand.
|
|
101
|
+
|
|
102
|
+
- **Photo thumbnails: `thumbnail` option** (`openGallery`, `openEditor`). The app can now upload a list-size image next to the full photo (ZemTalk's server no longer resizes uploads).
|
|
103
|
+
- `thumbnail: { maxWidth, maxHeight?, mode?: 'fit' | 'cover', quality? (0.8), format?: 'webp' | 'jpg' }`. Results (and `onImageProcessed`) get `thumbnailUri` / `thumbnailWidth` / `thumbnailHeight` and the new `thumbnailFileSize`.
|
|
104
|
+
- `fit`: ratio = min(maxW/w, maxH/h, 1). `cover`: center crop to the box ratio, then exactly maxW×maxH; never enlarged (a smaller photo gives the largest centered crop in the box ratio, e.g. 100×80 → 80×80 for 200×200).
|
|
105
|
+
- Made from the same bitmap as `uri` (EXIF orientation, crop, drawing, filter, watermark applied); the original is not decoded again. New encode → no EXIF/GPS/XMP. WebP (Android `WEBP_LOSSY` on API 30+), jpg if webp fails.
|
|
106
|
+
- Without `thumbnail` nothing changes (no extra work, same results).
|
|
107
|
+
- **Videos:** the option does not change posters — they keep the existing rule (longest side 512, `video.posterFormat`).
|
|
108
|
+
- Files `turbo_picker_thumb_<time>_<index>.webp` next to the output photo, deleted after 24 h (iOS cleaner now also covers this prefix).
|
|
109
|
+
- Android: `PhotoThumbnail` (AAR) + both bridges (`newarch`/`oldarch` `processImages`). iOS: `makePhotoThumbnail` in the bridge for the gallery (library and camera), `processEditorResult` (openEditor and single edit) and `processEditorResultMulti`.
|
|
110
|
+
- iOS writes the thumbnail without a color profile (untagged = read as sRGB): Apple's sRGB ICC is ~3 KB, about 40% of a 360 px thumbnail (360×360: 7.7 KB → 4.5 KB). Android's own ICC chunk is 464 bytes and is kept.
|
|
111
|
+
- Checked with the example app (`RNAttachmentKitApp`, "썸네일" buttons) on the Android 15 emulator and the iOS simulator, with 4032×3024, 4032×3024 + EXIF orientation 6, 3000×3000 and 100×80 photos:
|
|
112
|
+
- `{ maxWidth: 360 }` → 360×270 / 270×360 / 360×360 / 100×80; `{ maxWidth: 200, mode: 'cover' }` → 200×200 ×3 / 80×80 — the same on both platforms. Orientation-6 photo comes out upright.
|
|
113
|
+
- No EXIF/XMP in the files (`webpinfo`); iOS files have no ICC either.
|
|
114
|
+
- `openEditor` with crop (16:9) + drawing → the thumbnail shows both.
|
|
115
|
+
- Time per photo (4 photos, send → done): Android emulator 99 ms → 136 ms (+37 ms, noisy), iOS simulator 99 ms → 104 ms (+5 ms).
|
|
116
|
+
|
|
117
|
+
- **Android: 16 KB page size support** (QA SYS-16KB). The APK had two 4 KB-aligned 64-bit libraries from this module, which shows "This app isn't 16 KB compatible" on Android 15+ 16 KB devices and blocks Play updates for targetSdk 35+.
|
|
118
|
+
- CameraX 1.3.x → 1.4.2 (AAR and Dist `build.gradle`): `libimage_processing_util_jni.so` is 16 KB-aligned from 1.4.0. No API changes needed (compiles as is).
|
|
119
|
+
- `libreact_codegen_RNAttachmentKitSpec.so` (the module's own CMake build — an empty dummy library) now links with `-Wl,-z,max-page-size=16384` and `-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON`. The AAR is built with NDK 27.1 (AGP 8.5 defaulted to r26, whose `libc++_shared.so` is 4 KB); the Dist bridge uses the app's `ndkVersion`.
|
|
120
|
+
- `deploy.rb` now also copies `android/CMakeLists.txt` to Dist (it never did, so CMake changes never reached the app).
|
|
121
|
+
|
|
122
|
+
- **Android: Back closes the photo/video viewer instead of the screen behind it** (QA VIEW-02). The overlay viewer registered an `OnBackPressedCallback`, but on Android ≤ 15 (or apps without predictive back) Back arrives as a key event and `ReactActivity.onBackPressed()` sends it straight to JS without asking the dispatcher, so the chat room behind the viewer closed.
|
|
123
|
+
- New `OverlayBackHandler`: wraps the window callback to take `KEYCODE_BACK` before the Activity, registers a `PRIORITY_OVERLAY` `OnBackInvokedCallback` on API 33+ (predictive back), and keeps the androidx callback. Used by `ImageViewerOverlayView` and `MediaGalleryOverlay`; the one opened last gets Back first. No app (JS) changes needed.
|
|
124
|
+
|
|
125
|
+
- **Android 14+: photo picker works with "Allow limited access"** (QA PERM-사진 일부 허용). With only `READ_MEDIA_VISUAL_USER_SELECTED` granted, the picker (and album list) checked `READ_MEDIA_IMAGES` only, never loaded, and showed an empty grid.
|
|
126
|
+
- `MediaPermissions` treats limited access as readable; the grid shows the photos/videos the user allowed.
|
|
127
|
+
- A bar above the grid ("Only allowed photos are shown · Select more", 59 languages) re-requests the permission so the system selection screen opens again, then reloads the list. The instant-open cache is skipped in limited mode.
|
|
128
|
+
- Test app (`android/app`) opens the picker with limited access too, like the RN module.
|
|
30
129
|
|
|
31
130
|
## 1.0.8 — 2026-10-16
|
|
32
131
|
|
|
33
|
-
- **
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
-
|
|
132
|
+
- **iOS: closing the AI screen right after using a menu left a stray button on the gallery**. On iOS 26 a menu button morphs into its menu and takes about 0.5–1 s to morph back after the menu closes. Tapping ✕ (or Save/Apply) during that time dismissed the screen while the button was still detached, so the button (e.g. "나이", "실사") stayed over the gallery and slid down late.
|
|
133
|
+
- The menu buttons (age, style, situation, country, options, edit) are now `AIMenuButton`, which reports when its menu is open or still closing (`UIContextMenuInteraction` will-display / will-end + animator completion). `dismissAfterMenus()` waits for that (at most 1.2 s) before dismissing; with no menu involved it dismisses at once.
|
|
134
|
+
- Checked on the simulator with a screen recording: open the style menu, tap ✕ to close it, tap ✕ again 0.6 s later → the screen closes with the buttons in place, nothing left on the gallery.
|
|
135
|
+
|
|
136
|
+
- **AI screens are the same in every app (iOS + Android); simpler generate screen without the glamour content**.
|
|
137
|
+
- Generate (every app, including ZemTalk): woman / man + country, then age · style · situation, aspect ratio + Create, "Quick create from samples" chips, and the chat input. Text typed in the chat is sent together with the chosen gender, country, age and style (a person line that the request can override).
|
|
138
|
+
- Removed from the screen (data kept in `AIPromptExamples`): the "Glamour" (매혹 화보) situation section, glamour sample chips (`featured`), the detail options menu (framing, light, hair, body type, face, quality), and the random pose / outfit-colour variation. Samples and situations come only from the everyday situation lists (daily life, café & date, travel, city night, hobby, fashion, party, work, retro, fantasy, seasons, art styles). Camera angle / expression variation and the celebrity-look prompt stay.
|
|
139
|
+
- New age pill (shows "나이" for the default, the chosen age otherwise). Image size stays 512 (no quality choice on this screen).
|
|
140
|
+
- Age now goes up to the 80s: 50s, 60s, 70s and 80s added (each tells the model to show the person clearly at that age — wrinkles, grey/white hair — and overrides the youthful-look wording). New labels `ail_50s`…`ail_80s` in 59 languages; languages that showed the 40s as "40+" now show "40–49" so the ranges don't overlap.
|
|
141
|
+
- Tapping a sample chip shows the current settings in the confirmation before creating: gender · age · country, then style · aspect ratio (plus the free-use / points line as before).
|
|
142
|
+
- Note: `scripts/ai_prompts/generate.py` is older than the hand-edited `AIPromptExamples.swift/.kt` (it would drop the 59-language menu labels), so the four age items were added to both files by hand and to `prompts.json`; don't run that generator until it is brought up to date.
|
|
143
|
+
- Edit (every app): the full editing screen (edit choices, options, recommendation chips + chat).
|
|
144
|
+
- `fullFeatures` no longer comes from `AIGemini.isLicensedApp`; `showsFullGenerate` / `SHOWS_FULL_GENERATE` (true) switches generate between this screen and chat-only. `isLicensedApp` is kept but unused.
|
|
145
|
+
- Every generated and edited image is stored in `AIRecentWorks`, so the gallery's "AI 이미지" tab works in every app.
|
|
146
|
+
- README AI section updated (Source + Dist).
|
|
147
|
+
- Checked: Android S23 test app (layout, situation menu without the glamour section, edit = full screen); iOS simulator (layout). Android compiled, iOS typechecked.
|
|
148
|
+
|
|
149
|
+
- **iOS: the "AI 이미지" tab moves the sheet like the gallery grid**. Dragging the AI images list up while the sheet is at half height expands it to full height and keeps scrolling. Pulling the list down from the top while expanded drags the sheet back down to half height (or closes it, same thresholds as the gallery).
|
|
150
|
+
- Before: the list only scrolled inside the half-height sheet, and pulling down at the top did nothing.
|
|
151
|
+
- `GalleryViewController` uses the visible list (gallery grid or AI images list) for the collapsed drag-to-expand and its follow-on scroll / momentum. The expanded pull-down (`handleScrollPan`) is attached to the AI list too. `AIWorksPanelView.lockAtTop` pins the list at the top while the sheet is being pulled.
|
|
152
|
+
- Android already behaved like this (the panel's `RecyclerView` becomes the sheet's nested-scroll child); checked again on the S23.
|
|
153
|
+
- Checked on the iOS simulator: expand from half height, scroll, pull down at the top → back to half height; the gallery tab still works the same.
|
|
154
|
+
|
|
155
|
+
- **iOS: an edited video's first poster comes from the edited video**. `onProcessingStart` sent a poster made from the original's first frame with crop and drawing applied. When the start was trimmed, that frame is not in the sent video.
|
|
156
|
+
- The poster is now the frame at the trim start (`AVAssetImageGenerator`, no tolerance before the start, 0.1 s after), then crop and drawing are applied. Untrimmed videos keep the existing poster. Same as Android (`VideoExporter.preview`).
|
|
157
|
+
- The final result's `thumbnailUri` was already taken from the exported file.
|
|
158
|
+
- Typecheck only (the RN bridge is not part of the test harness).
|
|
159
|
+
|
|
160
|
+
- **Android image viewer: video controls no longer flash on photo pages when the viewer opens**.
|
|
161
|
+
- Opening a photo in a group that also has videos showed the ▶ button and the progress / options pills during the open animation; they disappeared when the animation ended.
|
|
162
|
+
- Cause: `ViewerVideoController` hides the controls in `init` (`buildControls` → `setVisible(false)`), but `buttonTarget` / `pillsTarget` were declared below `init`. At that moment they still held the JVM default 0, which means "already hidden", so the hide was skipped and the views stayed visible until the first page update after the open animation.
|
|
163
|
+
- The two fields are now declared above `init`.
|
|
164
|
+
- Checked on the S23 test app (photo page between two videos): no controls during or after the open animation; a video page still shows them.
|
|
38
165
|
|
|
39
166
|
## 1.0.6 — 2026-10-14
|
|
40
167
|
|
|
41
|
-
- **
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
- **Android
|
|
47
|
-
-
|
|
168
|
+
- **Android: the editor zoom lands exactly on the video when opening and starts from it when closing**.
|
|
169
|
+
- Before, the open animation always targeted a full-screen fit in the centre. A video page actually sits between the top bar (system bar + 60 dp) and the trim strip / tool bar (system bar + 120 dp). So the video first grew to the screen centre, then jumped to its real place; a square video zoomed to the middle and then moved up. The close animation of a video page also started from the centred full-screen rect.
|
|
170
|
+
- New `videoPageRect`, the same calculation as `EditorVideoPageFragment`'s padded `PlayerView` (fit, centred in that area). It is the open target for video pages and the close start, with the pull-down scale and offset applied around the screen centre.
|
|
171
|
+
- Compiled and checked on the S23 test app: a screen recording shows the video settling in its page area. Recording frame rate was too low to see every in-between frame.
|
|
172
|
+
|
|
173
|
+
- **Android: the gallery camera cell no longer disturbs scrolling, opening or closing the editor**.
|
|
174
|
+
- The camera cell has its own view type (`ImageGridAdapter.VIEW_TYPE_CAMERA`, pool size 1), so it is never recycled as a photo cell. Scrolling back brings the same `PreviewView`, still bound and live, with no rebind and no last-frame cover.
|
|
175
|
+
- Before: the cell came back with another holder's preview and the camera was rebound (~440 ms on the main thread).
|
|
176
|
+
- `onViewDetachedFromWindow` no longer copies the preview (`previewView.bitmap`, a GPU readback) while scrolling.
|
|
177
|
+
- The last-frame snapshot used to run every second, also during scrolls. It now runs every 2.5 s and only while the grid and sheet are idle.
|
|
178
|
+
- The camera now uses its own lifecycle (`LifecycleRegistry`). It is bound once; switching it off or on moves only that lifecycle between CREATED and RESUMED. The camera device closes and opens on CameraX's thread instead of `unbindAll` + `bindToLifecycle` on the main thread. Rebinds remain only for real changes (flip, flash, resolution, settings).
|
|
179
|
+
- Opening the editor: the camera is paused 0.6 s after `onPause`, after the editor's open animation (immediately on `onStop`). Returning cancels a pending pause.
|
|
180
|
+
- Measured on the S23 test app (logcat):
|
|
181
|
+
- Scrolling back to the camera: "already bound + live", no skipped frames.
|
|
182
|
+
- Editor open → pause: 2 ms. Close → resume: about 0.1 s (was about 0.4 s rebind + "Skipped 40+ frames").
|
|
183
|
+
- The first bind after the sheet settles still takes about 0.4 s on the main thread.
|
|
184
|
+
|
|
185
|
+
- **iOS: no empty-cell flash when a gallery photo opens in the editor**.
|
|
186
|
+
- Cause: tapping a photo hid its grid cell (sticky source, `setHiddenAsset`) right away, but the zoom only started a few frames later, once the editor view controller had been created and laid out. For those frames the cell showed as an empty dark square.
|
|
187
|
+
- The cell is now hidden in the transition's first frame. `ImageEditorTransitionDelegate.onPresentationStarted` is passed to the animator and called right after the zoom image is placed over the cell, so the cell and the zooming copy swap within the same frame. The bridge sets it instead of hiding the cell at tap time.
|
|
188
|
+
- Typecheck only. The test harness opens the editor through its own path, so the flash can't be reproduced there.
|
|
189
|
+
|
|
190
|
+
- **Android: gallery opens without waiting for the camera**.
|
|
191
|
+
- Measured in ZemTalk on the S23 (logcat): the camera cell asked for the camera about 40 ms after the sheet was created. CameraX `bindToLifecycle` then held the main thread for about 330 ms in the middle of the sheet's slide-up ("Skipped 41 frames"), so the sheet settled only about 900 ms after `onCreate`.
|
|
192
|
+
- The sheet now starts with a camera pause reason `opening`. The camera cell keeps showing the last frame while the sheet opens. When the sheet first settles (`STATE_COLLAPSED` / `STATE_EXPANDED`, or 0.9 s at the latest), the pause is lifted one frame later and the camera binds.
|
|
193
|
+
- `ProcessCameraProvider.getInstance` is started in `onCreate`, so CameraX initialises in the background while the sheet animates.
|
|
194
|
+
- Compiled and installed on the S23 test app; not yet measured by hand (the phone was in use).
|
|
195
|
+
- **Android: the gallery camera is no longer switched off and on by scrolling or tab changes, and not during the editor pull-down**. Each bind or unbind holds the main thread for about 300 ms and was cutting into movement.
|
|
196
|
+
- Scrolling the camera cell out of view and switching attach tabs (Location / File / AI images) leave the camera running. If the cell comes back after its surface was destroyed, only the preview output is reattached (`Preview.setSurfaceProvider`), with no rebind.
|
|
197
|
+
- Editor pull-down: the camera is no longer started when the drag begins (`TransitionHelper.onEditorPeek` is not used any more). The cell shows the last frame. The camera starts once the editor's close animation has finished: `onResume`, 120 ms later.
|
|
198
|
+
- iOS is unchanged: it still restarts at the start of the pull, because its session runs separately.
|
|
199
|
+
- The camera is switched off only when another screen covers the gallery (`lifecycle`) and while the sheet is opening (`opening`).
|
|
200
|
+
- Compiled and installed on the S23 test app; not yet tried by hand.
|
|
201
|
+
|
|
202
|
+
- **Gallery attach menu: "AI 이미지" tab (iOS + Android)**. When images made or edited with AI exist on the device, a fourth tab appears at the right of the bottom menu (Gallery / Location / File / ✦ AI 이미지).
|
|
203
|
+
- Selecting it shows those images in the gallery panel itself (3-column grid in place of the photo grid; title "AI 이미지"), like the Location and File tabs.
|
|
204
|
+
- Tapping an image opens the image viewer with a zoom from the cell: view, save, share, and delete with confirmation. Deleting removes the file; the panel refreshes at once.
|
|
205
|
+
- When the last image is deleted, the menu goes back to Gallery and the tab disappears. The tab also appears or disappears live (`AIRecentWorks` change notification / listeners).
|
|
206
|
+
- The store (`AIRecentWorks`, app data folder, max 300) now also keeps every AI **edit** result, not only generated images. ZemTalk/licensed apps only; the chat-only public mode doesn't store anything.
|
|
207
|
+
- iOS:
|
|
208
|
+
- `AttachMenuTab.aiWorks` (sparkle icon, same path as Android) and `AttachMenuBar.setTabs(_:)`, which works on both the native iOS 26 `UITabBar` and the custom capsule bar.
|
|
209
|
+
- `AIWorksPanelView` (in FilePanelView.swift; ImageIO thumbnails).
|
|
210
|
+
- `GalleryViewController.show/hideAIWorksPanel`; the viewer is `RemoteImageViewerViewController` with the same zoom transition as the media gallery.
|
|
211
|
+
- Android:
|
|
212
|
+
- `AttachMenuTab.AI_WORKS` (`ic_attach_ai`) and `AttachMenuBarView.setTabs`; the bar widens for four tabs.
|
|
213
|
+
- `AIWorksPanelView` (RecyclerView + Glide).
|
|
214
|
+
- The viewer runs in a transparent `AIWorksActivity` so it shows above the picker's bottom-sheet dialog (`ImageViewerOverlayView` + `GalleryHooks.onDelete`).
|
|
215
|
+
- New string `ai_works` (59 languages).
|
|
216
|
+
- iOS: while the viewer is open (and during its pull-down), the cell's place showed red. The zoom animator masks the source cell with `sourceBackgroundColor`; its default `.clear` falls back to red, and the AI images viewer didn't set it. It now passes the gallery background (light white / dark rgb 29). Checked on the simulator: the cell slot shows the white gallery background during the pull-down. Android already passed the sheet background. No RN setting is needed.
|
|
217
|
+
- Checked:
|
|
218
|
+
- iOS simulator: the tab appears with the images, the panel opens, the viewer opens from a cell, and delete → confirm removes the file and refreshes the panel.
|
|
219
|
+
- Android: compiled and installed on the S23 (test app, with a sample AI image placed in its store). Not yet tried by hand.
|
|
220
|
+
|
|
221
|
+
- **Image viewer video: controls readable in light mode; pinch zoom on video pages (iOS + Android)**
|
|
222
|
+
- iOS 26: Liquid Glass gets lighter or darker on its own depending on what's behind it, so the white icons and text disappeared on light glass. The backgrounds stay the system glass. Only the content colour now follows the glass's actual light/dark state: dark content on light glass, white on dark glass.
|
|
223
|
+
- Pills: `ViewerPillView` watches its glass `contentView`'s `userInterfaceStyle` and passes it to the labels, slider and buttons on top.
|
|
224
|
+
- Center button: `ViewerPlayButton` now uses the same glass background and passes the style to its icon and spinner.
|
|
225
|
+
- Colour helper: `viewerControlForeground` (light → near-black, dark → white). The slider thumb redraws when the style changes.
|
|
226
|
+
- iOS 25 and earlier: the pill and button backgrounds follow the app mode (light → light translucent), so the dark content stays readable.
|
|
227
|
+
- Checked on the simulator in light mode: dark icon on the light glass button over bright video, white text on the pills over the black letterbox.
|
|
228
|
+
- Video pages zoom like photos (pinch / double-tap).
|
|
229
|
+
- iOS: the fixed `maximumZoomScale = 1` on `ViewerVideoItemView` is gone. The player layer lives in the zoomed image view, so it scales with it. Checked on the simulator.
|
|
230
|
+
- Android: video pages no longer disable zoom on the poster `ZoomableImageView`. The `TextureView` surface follows the poster's matrix (`ViewerVideoController.syncSurface`: fit rect → current rect scale/translation, pivot 0,0). The listener is removed on detach. Compiled and installed on the S23, not yet tried by hand.
|
|
231
|
+
|
|
232
|
+
- **Perf pass over the in-app camera recording (no behaviour change)** — from `docs/perf_review_2026-10-14.md`: iOS disables the "last photo" `AVCaptureVideoDataOutput` connection while recording (no 30 fps BGRA frame copies during a recording) and re-enables it afterwards; recordings are **moved** into the photo library (`PHAssetResourceCreationOptions.shouldMoveFile`) instead of copied; the 1:1 crop export skips its unused poster (`VideoExporter.makesThumbnail = false`); the single-select poster is fetched asynchronously instead of a synchronous main-thread `PHImageManager` request; the `● 0:12` label redraws only when the second changes. Android records Full / 16:9 clips **straight into MediaStore** (`MediaStoreOutputOptions`, Movies; duration written for the grid badge) instead of cache → copy, 4:3 / 1:1 keep the cache file for the crop; the crop copy uses a 256 KB buffer; the per-bind stack-trace debug log is gone. Not applied (left for later, see the review): binding `VideoCapture` only while the full-screen camera is open, CameraX 1.4 viewport crop for video, lazy video source resolution in the viewer.
|
|
233
|
+
- **AI: full features only in ZemTalk; other apps get chat-only generate/edit (iOS + Android)**
|
|
234
|
+
- Any app can turn AI on with `setAIConfig({ enabled: true, endpoint | apiKey })`, as before.
|
|
235
|
+
- The full AI screen is reserved for licensed apps: ZemTalk `com.zemtalk`, `.dev`, `.stag`, plus the module's own test apps.
|
|
236
|
+
- Generate: gender/country, style / detail options / situations, the "make" button, the samples section.
|
|
237
|
+
- Edit: style / options / edit menu, recommendation chips with device and Gemini analysis, the "edit" button.
|
|
238
|
+
- Both: recent works, and the hidden prompt additions (beauty/variation/edit-guard instructions and the safe retry after a block).
|
|
239
|
+
- Every other app (installed from npm) gets the chat-only screen: the user's text is sent as is.
|
|
240
|
+
- Generate: aspect ratio bar, chat input, and thumbnails of what was made.
|
|
241
|
+
- Edit: photo, chat input, and thumbnails of the original and results.
|
|
242
|
+
- Billing (`freeCount` / points / `onAIUsage`) works the same.
|
|
243
|
+
- The licence check compares SHA-256 of the lowercased bundle ID (iOS) or package name (Android, from the process name, no Context) against a list, so the app IDs don't appear in the binaries. The check is `AIGemini.isLicensedApp` and `licensedAppHashes` / `LICENSED_APP_HASHES` on both platforms. To add an app: `echo -n "<id>" | shasum -a 256`.
|
|
244
|
+
- Harness: launching the iOS app with `-aiPublicMode` shows the chat-only screen (it can only reduce rights).
|
|
245
|
+
- Checked:
|
|
246
|
+
- iOS typecheck, plus the simulator for the chat-only generate (ratio + input) and edit (photo + input) screens, and the full generate screen unchanged without the flag.
|
|
247
|
+
- Android: compile, installed on the S23 (licensed test app). The chat-only screen was not checked on Android.
|
|
248
|
+
|
|
249
|
+
- **Android: 3:4 (and other ratios) fill the screen width**. `targetRect` used to narrow the camera frame as soon as the ratio was taller than the space between the top pills and the area 44 dp above the shutter. On the S23, 3:4 was slightly too tall for that space, so it got side margins. The frame now keeps the full width and moves up into the spare space first, down to just under the pills and up to just above the shutter. It is narrowed only if even that space is too short (e.g. 9:16 on short screens). Compiled and installed on the S23.
|
|
250
|
+
|
|
251
|
+
- **Default lens back to 1×; smooth pinch zoom; tap keeps auto exposure; Android stabilization only while recording (iOS + Android)**
|
|
252
|
+
- Default lens is 1× again on both platforms: the gallery cell, the full-screen camera, and after flipping back. The ultra-wide default looked odd. Android `applyDefaultZoom` now targets 1.0 instead of the minimum zoom.
|
|
253
|
+
- iOS lens switching without jumps: the back camera is now the virtual multi-lens device, like the system Camera app (`.builtInTripleCamera` → `.builtInDualWideCamera` → `.builtInDualCamera` → wide).
|
|
254
|
+
- Zooming only changes `videoZoomFactor`, and the system hands over between ultra-wide, wide and tele. Before, the camera input was swapped (removed and re-added), which jumped the preview and stopped a recording.
|
|
255
|
+
- Zoom is now handled in display units, where 1× is the first switch-over factor: `oneXFactor(for:)`, `applyOneX`, `displayZoom`, `setDisplayZoom`. It is capped at 10×.
|
|
256
|
+
- Pinch and the 0.5× / 1× / 2× buttons just zoom (buttons ramp). While pinching, the selected button shows the live value ("1.4×").
|
|
257
|
+
- Removed: `switchToUltraWide` / `switchToWideAngle` and the overlay's snapshot-covered lens transition.
|
|
258
|
+
- Record start/stop keeps the zoom across the format and preset change.
|
|
259
|
+
- This also gives the system's automatic lens choice, e.g. close focus on Pro models.
|
|
260
|
+
- Android pinch: the zoom is tracked locally from the gesture's scale. It used to read the camera's LiveData zoom, which arrives late, and amplify the delta 3×, so the value jumped back and forth. A small "1.4×" label shows above the shutter while pinching. The physical lens switch inside the logical camera stays the HAL's.
|
|
261
|
+
- iOS tap-to-focus used one-shot `.autoFocus` / `.autoExpose`, which left exposure (and shutter speed) fixed after a tap. It now uses `.continuousAutoFocus` / `.continuousAutoExposure` at the tapped point, like the system camera. Shutter speed and ISO are automatic on both platforms; the only fixed exposure is the short lock while the gallery cell camera pauses and resumes.
|
|
262
|
+
- Android stabilization (EIS) is set only while recording, through `Camera2CameraControl` request options with no rebind. Keeping it on all the time cropped the photo preview, making it narrower than the system camera.
|
|
263
|
+
- Checked: iOS typecheck, Android compile, installed on the S23. Lens switching and exposure need a real device.
|
|
264
|
+
|
|
265
|
+
- **Camera settings: timer, grid, level, tap exposure, stabilization, 30/60 fps, front screen flash, selfie mirror (iOS + Android)**
|
|
266
|
+
- Top bar: a timer button next to the flash (iOS; top-left on Android). Tapping cycles off → 3s → 10s, and it turns yellow when on. A big countdown runs in the middle of the camera; tapping the shutter again cancels it. The timer resets each time the camera opens.
|
|
267
|
+
- New ⚙ button left of the resolution pill. These settings persist (iOS `UserDefaults` `rnak.camera.*`, Android `SharedPreferences` `rnak_camera`):
|
|
268
|
+
- Grid (3×3, off by default).
|
|
269
|
+
- Level (off by default). Visible within 12° of the held orientation; turns yellow with a tick haptic within 1°.
|
|
270
|
+
- Stabilization (on by default; video only).
|
|
271
|
+
- Selfie mirror (on by default; front-camera photos and videos saved as previewed).
|
|
272
|
+
- Frame rate 30 / 60 fps (video only; 60 appears only when the camera supports it at the current resolution).
|
|
273
|
+
- Tap to focus now also shows a ☀ handle beside the focus box. Dragging it up or down along the device's "up" sets exposure compensation (50 pt/dp per EV, ±2 EV within the camera's range). It hides after 2.5 s idle, and a new tap resets the exposure to 0.
|
|
274
|
+
- iOS: `setExposureTargetBias`.
|
|
275
|
+
- Android: this adds tap-to-focus, which Android didn't have before (`FocusMeteringAction` AF + AE), plus `setExposureCompensationIndex`.
|
|
276
|
+
- Front camera with flash: the screen turns warm white at full brightness and the photo is taken 0.45 s later; Auto uses it only in low light (ISO ≥ 40% of the max). iOS restores `UIScreen.brightness`; Android sets the dialog window's `screenBrightness` and restores it.
|
|
277
|
+
- Menus while the phone is held sideways:
|
|
278
|
+
- iOS: the system `UIMenu` only opens in screen (portrait) orientation, so in landscape the pills show `CameraRotatedMenu` instead. It uses the same rows (✓, icon, title, subtitle, section separators and titles) on a dark blur, rotated to the device orientation and placed beside the pill. Portrait keeps the native menu.
|
|
279
|
+
- Android: the menus are no longer a `PopupWindow`. They are a panel inside the overlay, rotated to the device orientation (tapping outside closes it).
|
|
280
|
+
- Implementation details:
|
|
281
|
+
- iOS: `startVideoRecording(…, fps:, stabilization:, mirrorFront:)` picks a format that does 60 fps at the chosen resolution, falling back to 30. Photo and movie connections set `isVideoMirrored` explicitly.
|
|
282
|
+
- Android: `VideoCapture.Builder` with `setMirrorMode`, `setTargetFrameRate`, and Camera2Interop `CONTROL_VIDEO_STABILIZATION_MODE` / `CONTROL_AE_TARGET_FPS_RANGE`; the camera rebinds behind a last-frame cover when one of these changes. Photo mirroring goes through `ImageCapture.Metadata.isReversedHorizontal`.
|
|
283
|
+
- New Android vectors: `ic_camera_timer/sun/grid/level/stabilize/mirror`.
|
|
284
|
+
- New strings `camera_grid/level/stabilization/mirror_front/frame_rate` (59 languages).
|
|
285
|
+
- Fixes:
|
|
286
|
+
- iOS zoom pill: after the 0.5× button was hidden (no ultra-wide), the stack kept the 3-button width and stretched the selected "1×" into a long capsule. The width is now computed from the visible buttons.
|
|
287
|
+
- Android: the first build crashed when ⚙ was tapped, because the panel was measured at full screen width and the clamp range was inverted. Fixed in this entry.
|
|
288
|
+
- Harness: `-cameraFakeLandscape` (simulator, with `-cameraOverlayTest`) fakes a sideways phone to check the rotated UI.
|
|
289
|
+
- Checked:
|
|
290
|
+
- iOS: typecheck, plus the simulator for the ⚙ menu, grid, timer, focus box with ☀, rotated pills and icons, and the rotated ⚙ menu.
|
|
291
|
+
- Android: compile, and on the S23 the new top bar with timer, ⚙, FHD and ratio. The ⚙ crash is fixed in code and installed, but not re-tested yet.
|
|
292
|
+
|
|
293
|
+
- **Camera captures in the direction the phone is held; screen stays portrait; video resolution menu (iOS + Android)**
|
|
294
|
+
- The ratio menu is back to 4 options: 꽉 참 / 9:16 / 3:4 / 1:1. The wide 4:3 / 16:9 entries from the previous change are removed (iOS `landscape4x3/landscape16x9`, Android `L4_3/L16_9`). Holding the phone sideways now gives the wide shot from the full sensor, like the system camera. For example, 9:16 held sideways → a 16:9 photo or video, and 3:4 → 4:3.
|
|
295
|
+
- The app UI no longer rotates while the camera is open.
|
|
296
|
+
- iOS: `CameraOverlayHostViewController` is portrait-only.
|
|
297
|
+
- Android: the activity is set to `SCREEN_ORIENTATION_PORTRAIT` while the overlay is shown, and the previous value is restored when it closes or the sheet is destroyed.
|
|
298
|
+
- Only the camera controls rotate, with animation: flash, flip, close, the zoom buttons (iOS), and the top pills, which stand upright along the top edge.
|
|
299
|
+
- How the device orientation is read:
|
|
300
|
+
- iOS: CoreMotion accelerometer (`CoreMotion` added to the podspecs).
|
|
301
|
+
- Android: `OrientationEventListener`.
|
|
302
|
+
- Both use hysteresis. Flat or upside-down keeps the last orientation.
|
|
303
|
+
- How the orientation is applied:
|
|
304
|
+
- Photos: iOS sets the photo connection's `videoOrientation` at capture time; Android sets `ImageCapture.targetRotation`.
|
|
305
|
+
- Videos: the orientation is taken at record start (iOS movie connection orientation, Android `VideoCapture.targetRotation`).
|
|
306
|
+
- The center crop uses the landscape ratio (1/hpw) when the shot is landscape. The iOS gallery video crop reads the recorded file's display size; Android remembers the orientation used at record start.
|
|
307
|
+
- The ratio pill shows the selected ratio's icon in front of the text (iOS: SF Symbol via `UIButton.Configuration`; Android: the outlined ratio shape).
|
|
308
|
+
- New resolution pill to the left of the ratio pill (video only, hidden when the gallery takes photos only): HD 720p / **FHD 1080p (default)** / 4K 2160p. 4K appears only when the current camera can record it, and switching to a camera without 4K falls back to FHD.
|
|
309
|
+
- iOS: `UIMenu` with subtitles; `startVideoRecording(…, orientation:, minShortSide:)` picks the recording format for that short side (`supportsRecording(shortSide:)` checks 16:9 at 30 fps).
|
|
310
|
+
- Android: popup like the ratio menu. Recorder `QualitySelector` (HD / FHD / UHD with fallback), and the camera rebinds behind a last-frame cover when the resolution changes. If the 4K stream combination can't bind, it retries with FHD before dropping video. 4K support is read from `Recorder.getVideoCapabilities(…).getSupportedQualities(SDR)`.
|
|
311
|
+
- Checked: iOS typecheck, harness build, and on the simulator (`-cameraOverlayTest`): the resolution menu (HD/FHD with ✓; no 4K on the simulator), the 4-option ratio menu with icons, and the pills updating. Rotation can't be tried on the simulator (no accelerometer). Android compiled only; not yet tried on the S23.
|
|
312
|
+
|
|
313
|
+
- **Camera ratio menu: native iOS menu, icons on both platforms**
|
|
314
|
+
- iOS: the custom-drawn dropdown is gone; the ratio pill uses the system `UIMenu` again, with SF Symbol ratio icons (`iphone`, `rectangle.ratio.9.to.16`, `rectangle.ratio.3.to.4`, `square`, `rectangle.ratio.4.to.3`, `rectangle.ratio.16.to.9`) and ✓ on the current ratio.
|
|
315
|
+
- Why the system menu never opened before: the full-screen camera was a view attached straight to the window, so UIKit had no view controller to present the menu from (the window's root was already presenting the gallery).
|
|
316
|
+
- The camera now lives in a transparent, non-animated `CameraOverlayHostViewController` (`overFullScreen`) presented by the gallery. It is dismissed right after the camera view leaves, and the close cover goes on the real window.
|
|
317
|
+
- The gallery cell camera's "covered by a presentation" check ignores this host.
|
|
318
|
+
- Simulator: launch the harness with `-cameraOverlayTest` to open the camera screen without a camera. Checked there: the menu opens, 16:9 animates to a centered wide frame, and the gallery is usable after closing.
|
|
319
|
+
- Android: each popup row now starts with an outlined icon in the ratio's shape (a phone outline with a home bar for 꽉 참) and ends with ✓ on the current one (`aspectIcon`). Compiled only (the S23 was in use).
|
|
320
|
+
- **Camera ratios in both orientations (iOS + Android)**: the ratio menu is now 꽉 참 / 9:16 / 3:4 / 1:1 / 4:3 / 16:9.
|
|
321
|
+
- The tall portrait ratios are labelled 9:16 and 3:4 (they used to read "16:9" / "4:3"); the new 4:3 and 16:9 are wide frames centered on screen, like 1:1.
|
|
322
|
+
- Photos are center-cropped from the 4:3 sensor image to any ratio.
|
|
323
|
+
- Videos are recorded in the portrait 3:4 or 9:16 format and center-cropped afterwards. On iOS the crop is now generic: a wider target crops the height, a taller one crops the width.
|
|
324
|
+
- Because the camera records portrait, wide 16:9 and 4:3 videos are limited to the recording's width: about 1080×608 and 1080×810.
|
|
325
|
+
- New cases: iOS `CameraCaptureAspect.landscape4x3 / landscape16x9` (plus `centeredVertically`), Android `CameraAspect.L4_3 / L16_9` (enum order = menu order).
|
|
326
|
+
- Checked: iOS typecheck and Android compile.
|
|
327
|
+
- **Fix: zooming while recording stopped the recording**.
|
|
328
|
+
- iOS: pinching across 1× (ultra-wide ↔ wide, more likely now that ultra-wide is the default) or tapping a 0.5×/1×/2× button swapped the session's camera input, which ends an `AVCaptureMovieFileOutput` recording. While recording, the lens is never swapped. Pinch zooms digitally on the current lens, and the zoom buttons match the field of view digitally (on ultra-wide: 0.5× = 1, 1× = 2, 2× = 4 zoom factor; on wide: 1×/2×). A 0.5× tap on the wide lens is ignored. Outside recording, nothing changes.
|
|
329
|
+
- Android: pinch zoom did nothing while recording, because the overlay returned before the scale detector. It now works during recording (pull-to-dismiss stays blocked). The lens switch happens inside the logical camera, so the recording continues.
|
|
330
|
+
- Checked: iOS typecheck and Android compile.
|
|
331
|
+
- **Video progress HUD: blur only, gradient edges (iOS + Android)**. The card is just a blur with no tinted overlay, and there is no dim over the screen.
|
|
332
|
+
- iOS: system dark blur at about 45% strength (a paused `UIViewPropertyAnimator` with `fractionComplete = 0.45`, stopped before the view leaves). The border is a gradient stroke, bright at the top-left and fading toward the bottom-right (`CAGradientLayer` masked by a stroked rounded path). Checked on the simulator.
|
|
333
|
+
- Android: blur behind the card only (`setBackgroundBlurRadius` 16 dp; no full-screen blur, dim 0). The card drawable is a gradient border (top-left → bottom-right, 1.2 dp) with a transparent inside. On Android 11 and below, which can't blur, the inside is a dark fill so the text stays readable.
|
|
334
|
+
- **Video progress HUD is translucent glass (iOS + Android)**.
|
|
335
|
+
- iOS: `systemUltraThinMaterialDark` blur with a hairline light border, and the dim behind lowered from 0.45 to 0.28 (checked on the simulator: the screen behind shows through, blurred).
|
|
336
|
+
- Android: the card is now the window background, a rounded translucent drawable (55% alpha, hairline border; 85% where blur isn't available). On Android 12+ the area behind the card is blurred (`setBackgroundBlurRadius` 28 dp) plus a light `FLAG_BLUR_BEHIND`. The dim is 0.28, and the card fades in with a slight scale. It used to be an almost opaque gray card (95% alpha).
|
|
337
|
+
- **iOS: pulling down the editor right after a capture showed the top bar sliding down**.
|
|
338
|
+
- Cause: the capture flow opened the editor with `sourceFrame = .zero`, so no zoom transition was set and the default cover-vertical dismissal was used. When the user pulled down, the bars had been moved off-screen (top bar up by 250), and the whole editor then slid down, bringing the top bar back into view.
|
|
339
|
+
- Fix: after a capture the editor now opens from the new grid cell (`frameForAsset`, with the captured image), using the same zoom open/close as tapping a cell. Any editor opened without a source frame uses a cross-dissolve, so pull-down dismissal fades out instead of sliding.
|
|
340
|
+
- Checked: iOS typecheck. The capture path itself couldn't run on the simulator (no camera).
|
|
341
|
+
- **Camera: smooth ratio change (Android), working ratio menu (iOS), ultra-wide by default, smaller Android stop button**
|
|
342
|
+
- **Android ratio change no longer flashes**
|
|
343
|
+
- Before, every ratio change rebound CameraX with a new `ViewPort`, so the preview reconnected and flashed through the cover.
|
|
344
|
+
- Now preview and photo always use the 4:3 stream with no ViewPort. A ratio change only animates the visible frame (0.34 s, the `PreviewView` is a TextureView, so the live picture keeps running) and never rebinds.
|
|
345
|
+
- Photos are center-cropped to the chosen ratio after capture (`cropPhotoToAspect`: only the crop region is decoded with `BitmapRegionDecoder`, then rotated by EXIF).
|
|
346
|
+
- Videos (16:9 recording) are cropped for 4:3, 1:1 and now 꽉 참 too.
|
|
347
|
+
- **iOS ratio menu**: the `UIMenu` never appeared, because the camera screen is a plain view on the window and a system menu can't present there. It is replaced by a dropdown drawn in the camera screen (dark rounded list under the pill with a ✓, springs open from its top-right corner, closes on an outside tap); choosing a row animates the ratio change.
|
|
348
|
+
- **Ultra-wide (0.5×) by default where available**: the gallery cell and the full-screen camera start on the ultra-wide lens on devices that have one; others stay at 1×.
|
|
349
|
+
- iOS: `CameraPreviewView.defaultBackDevice()`; the overlay selects the 0.5× button, and flipping back to the rear camera returns to it.
|
|
350
|
+
- Android: after binding, the zoom is set to the minimum ratio only when it is below 1 (`applyDefaultZoom`). A zoom the user picked is kept across flash rebinds and reset when switching front/back.
|
|
351
|
+
- **Android locked-recording stop button** was drawn almost full size: the LayerDrawable inset change is only applied on the next bounds change, so only the corners morphed. The inner shape's bounds are now set directly (■ ≈ 26 dp inside the 72 dp shutter, corner 7 dp).
|
|
352
|
+
- **Checked**: iOS typecheck and Android compile. Not run on devices.
|
|
353
|
+
- **Full-screen camera: hold-to-record lock, and Android opens the editor after a capture**
|
|
354
|
+
- **Lock (iOS + Android, works like ZemTalk's voice recorder)**
|
|
355
|
+
- While recording with the shutter held, a dark pill (🔓 + ⌃) springs up above the shutter; the ⌃ nudges once to hint at the gesture.
|
|
356
|
+
- Sliding the finger up lifts the pill and fades the ⌃. At 80 pt/dp it locks: a haptic tap, 🔓 pops to 🔒, then the pill floats up and fades out.
|
|
357
|
+
- Once locked, the red ● in the shutter morphs into a red rounded square ■ (stop) with a spring. Recording continues after the finger is lifted, and tapping the shutter stops and saves.
|
|
358
|
+
- The whole ring area accepts the stop tap (iOS `ShutterHitButton.hitOutset`; on Android the inner drawable is a rounded rect whose inset and corner animate).
|
|
359
|
+
- Max duration and back still stop recording.
|
|
360
|
+
- iOS moves the record timer to the top center so it doesn't collide with the pill.
|
|
361
|
+
- **Android, capture → editor**: photos and videos from the in-app camera now open the editor right away when the editor is on, as on iOS (`presentCapturedMedia`). The capture is inserted at the top of the grid but not selected (selection happens in the editor), and the camera shrinks back behind the editor. With the editor off, or in profile mode, it is inserted and selected as before.
|
|
362
|
+
- **Checked**: iOS typecheck and Android compile. Not run on devices (no simulator camera; the S23 was in use).
|
|
363
|
+
- **Full-screen camera: ratio menu, animated ratio change, iOS "full", Android layout fixes**
|
|
364
|
+
- **Ratio menu (iOS + Android)**: the ratio pill now opens a list (꽉 참 / 4:3 / 1:1 / 16:9, ✓ on the current one) instead of cycling on each tap. iOS uses a `UIMenu` on the pill; Android a dark rounded `PopupWindow` below it.
|
|
365
|
+
- **Animated change**
|
|
366
|
+
- iOS: the camera area resizes with a 0.32 s ease, and the preview layer is animated with the same timing curve (`CameraPreviewView.animatePreviewLayer`). It used to jump behind a cross-fading snapshot.
|
|
367
|
+
- Android: the container animates over 0.32 s with the same curve. The snapshot cover now lifts once the rebound preview is actually streaming (IDLE → STREAMING, 1.5 s fallback) instead of after a fixed 0.7 s.
|
|
368
|
+
- **iOS "꽉 참 (full)"**: the preview fills the screen and the controls sit on top. Photos are center-cropped to the screen ratio. Videos record with the 16:9 format and are cropped to the screen ratio after recording, the same way 1:1 is handled (`CameraCaptureAspect.full`, `recordsSixteenByNine`, `GalleryViewController.handleCapturedVideo`). The menu order matches Android.
|
|
369
|
+
- **Android**
|
|
370
|
+
- The shutter, flash, flip and the "Tap for photo, hold for video" hint move up by the navigation-bar inset (3-button bar or gesture bar) instead of overlapping it, and the ratio area's bottom follows.
|
|
371
|
+
- The +/- zoom slider is removed; pinch zoom stays.
|
|
372
|
+
- **Checked**: iOS typecheck and Android compile. Not run on devices: the simulator has no camera, and the S23 was in use.
|
|
373
|
+
- **Video poster is webp by default, selectable from RN (iOS + Android)**: new `video.posterFormat?: "webp" | "jpg"` (default `"webp"`). It applies to every video poster the module makes — the `onProcessingStart` poster, and the `thumbnailUri` of the result / `onImageProcessed` (file name `…thumb.webp|jpg`; the exporter result carries the real size now: iOS `VideoExportResult.thumbnailWidth/Height`). Android encodes with `WEBP_LOSSY` (API 30+, `WEBP` below), iOS with `SDImageWebPCoder` through the new public `VideoExporter.encodePoster(_:format:quality:)`; if webp can't be encoded on iOS it falls back to jpg and the file extension says so. Photos are unchanged (`outputFormat`). TS: `VideoOptions.posterFormat`.
|
|
374
|
+
- **Video limits are checked before sending, not after (iOS + Android)**: a video that would fail (too long for `video.maxDurationSec`, or too large for `video.maxFileSizeMB` even at the lowest quality) can no longer be picked, and the user sees why while editing.
|
|
375
|
+
- **Gallery**
|
|
376
|
+
- The select circle only selects videos that can be sent; otherwise it shows `video_too_long` / `video_too_large`.
|
|
377
|
+
- With the editor on, tapping a long video now opens the editor (it used to be blocked).
|
|
378
|
+
- Judged on the edit state if the video was trimmed, else on the whole video (`videoSendIssue`).
|
|
379
|
+
- **Editor**
|
|
380
|
+
- A long video opens with its first `maxDurationSec` selected (`VideoEditState.clamped`; iOS `defaultState`, Android `defaultVideoState`).
|
|
381
|
+
- The trim strip stops at the max length (`VideoTrimView.maxDuration` / `maxMs`). Reaching it shows the "최대 N분" hint for 2 s.
|
|
382
|
+
- Above the strip, the right side shows the selected length (`1:23`, or `1:23 / 3:00` when the source is longer) and the left side shows the reason in red when it can't be sent (handles turn red too).
|
|
383
|
+
- Unsendable videos are not auto-selected and can't be selected or sent (badge, ✓ N send, single send). The background pre-encode is skipped for them.
|
|
384
|
+
- When a longer trim would push the chosen quality over the size cap, the quality drops to the highest step that fits (`fittedQuality`). The quality panel greys out steps using the same margin.
|
|
385
|
+
- **Encoder**
|
|
386
|
+
- Size estimates carry a 15% margin (`sizeMargin` / `SIZE_MARGIN`) for encoder overshoot.
|
|
387
|
+
- If the encoded file is still over the cap, it re-encodes one step lower; if nothing fits it fails with `FILE_TOO_LARGE` (iOS `ExportError.tooLarge`, Android `TooLargeException`) instead of returning an oversized file.
|
|
388
|
+
- The "copy the original" fallback and Android passthrough only happen when the original is within the cap. iOS also checks the cap when the quality is "original".
|
|
389
|
+
- Export clamps over-long ranges as a safety net (Android).
|
|
390
|
+
- **Harness**: `-videoLimitTest` (3-second limit).
|
|
391
|
+
- **Checked**: iOS simulator (blocked select with notice, editor opens at `0:03 / 0:03`, the handle stops with the red notice, select works, the encoded file is 3.0 s); Android compiles (not run on a device: the S23 was in use).
|
|
392
|
+
- **In-app camera: aspect ratio + long-press video recording (iOS + Android)**. The full-screen camera (camera cell → expand) gets a ratio pill at the top right and records a video when the shutter is held.
|
|
393
|
+
- Ratio: iOS `4:3` (default, unchanged framing) → `1:1` → `16:9`; Android `Full` (default, unchanged full-screen framing) → `4:3` → `1:1` → `16:9`. The preview area resizes to the ratio and the photo / video is what the preview shows. iOS keeps the `.photo` session and crops the still to the ratio (no re-encode, 4:3 stays the untouched original); Android binds Preview + ImageCapture (+ VideoCapture) in one `UseCaseGroup` with a `ViewPort` of that ratio: CameraX crops the preview and the JPEG (4:3 / 1:1 use the 4:3 stream, the rest 16:9 — a 4:3 photo on the S23 is 3060×2295), but `VideoCapture` ignores the viewport (always 1920×1080), so a 4:3 / 1:1 recording is cropped afterwards by `VideoExporter.cropToAspect` (media3 `Crop` effect, same resolution, progress HUD; ~2 s for a 3 s clip on the S23 → 1440×1080). Full / 16:9 recordings are saved as recorded.
|
|
394
|
+
- Video: hold the shutter ≥ 0.35 s → the ring turns red and shows `● 0:12`; release to stop (clips shorter than 0.3 s are discarded; `video.maxDurationSec` stops the recording automatically). Only when the gallery accepts videos (`mediaType` `'all'` / `'video'`); photo-only pickers keep the plain tap. The hint label now reads "tap for photo, hold for video" on iOS too. Flash "on" = torch while recording. Pull-to-close, back and the other buttons are disabled while recording.
|
|
395
|
+
- iOS records with `AVCaptureMovieFileOutput` after switching the device to a 4:3 or 16:9 video format (short side ≥ 1080, 30 fps, `.inputPriority`) and back to `.photo` afterwards; both switches are covered with a snapshot so the preview does not jump. Audio input is added only if the microphone is authorized (asked once when the full-screen camera opens; **host apps need `NSMicrophoneUsageDescription`**). 1:1 is recorded as 4:3 and cropped to a square by `VideoExporter` (original resolution, progress HUD) before saving.
|
|
396
|
+
- Android records with `camera-video` `Recorder` (FHD, falls back to ≥ HD) into the cache, then `MediaStore.Video` (Movies), sets its duration for the grid badge and selection rules, and goes through the same insert-and-select path as a photo. `RECORD_AUDIO` is declared by the module and requested when the full-screen camera opens (denied → silent video). If a device cannot bind preview + photo + video together, it rebinds without video (hold does nothing).
|
|
397
|
+
- Either way the recording is saved to the photo library, inserted at the top of the grid and selected (or opened in the editor / returned at once, exactly like a captured photo).
|
|
398
|
+
- Checked on the S23: open → mic prompt → Full / 4:3 / 1:1 / 16:9 pills (ratio taps are debounced to 600 ms), 4:3 photo cropped, held shutter → `● 0:02` → clip saved to Movies, inserted first and selected with its duration badge. iOS: library compiles and the harness app was built from a scratch copy of `ios/` and installed on the iPhone 15 (not driven — needs a hands-on check).
|
|
399
|
+
- i18n: `camera_hint` now generated for iOS, new `camera_ratio_full` ("Full"), Android `photo` string added. Harness `Info.plist` gets the microphone description.
|
|
400
|
+
- **Image viewer videos start muted (iOS + Android)**: the options pill is now `[🔇] [⚙] [⛶]`. Tap 🔇 to turn the sound on (🔊). The choice lasts until the viewer closes, so the next video in the same viewer keeps it. Android does not take audio focus while muted, so music from other apps keeps playing. Verified on the iOS simulator (starts muted, toggles); Android compiles.
|
|
401
|
+
- **Open the gallery again while a video is still encoding**: the JS lock (`E_ALREADY_OPEN`) is released when `onProcessingStart` arrives instead of when the Promise settles (a token keeps a late call from unlocking a newer one).
|
|
402
|
+
- Every processing event carries a **`processingId`** (`onProcessingStart`, `onVideoProgress`, `onImageProcessed`); option callbacks are filtered to their own send.
|
|
403
|
+
- `cancelVideoProcessing(index?, processingId?)`: no `processingId` means the latest send. Spec: `cancelVideoProcessing(index: number, processingId: string)`.
|
|
404
|
+
- Native: jobs are kept per `processingId`. iOS only clears `currentGalleryVC` if it is still its own gallery, and skips `VideoPrepareManager.discardAll()` (`releaseAfterReturn(discardPreparedVideos:)`) when a newer gallery is open. Android processes each send with a snapshot of that call's settings (`SendSnapshot`: max size, format, video options, edit states, HUD), because a new `openGallery` overwrites the module fields.
|
|
405
|
+
- The Promise still resolves with all results at the end.
|
|
406
|
+
- **Fix: an edited video's send could hang forever, and after cancelling it the gallery would not open again (iOS + Android)**. Editing pre-encodes in the background (`VideoPrepareManager`, one job per state change; the previous job is cancelled), and Send waits for that file (`takeReady`).
|
|
407
|
+
- iOS: `transcode` waited with `group.wait()`. After `cancelWriting()` (or a writer failure), the inputs never call their block again, so the wait never ended. That blocked the serial prepare queue, the final job never started, and Send waited at 0% forever. `loadAVAsset` had the same problem when the request was cancelled.
|
|
408
|
+
- Android: `Transformer.cancel()` does not call the listener, so the transcode coroutine never resumed.
|
|
409
|
+
- Both: `takeReady` could not be cancelled, so `cancelVideoProcessing` / HUD cancel did nothing while waiting, and the `openGallery` Promise never settled (`E_ALREADY_OPEN` on the next open).
|
|
410
|
+
- Now: the iOS waits check for cancel or failure every 0.2 s; Android resumes the coroutine on cancel; `takeReady(…, isCancelled:)` stops the job and returns, and the bridge then ends that video as cancelled instead of encoding it again.
|
|
411
|
+
- Checked: iOS typecheck and Android library compile. Not yet checked on a device (needs a Dist build).
|
|
412
|
+
- **Performance pass over today's video / viewer / camera code (no behavior change)**. Reviewed per area and fixed what measurably cost CPU, memory or main-thread time:
|
|
413
|
+
- *Viewer video (iOS)*: progress labels/slider only update when the shown second changes (every 0.1 s tick used to re-set `UILabel.text` → an Auto Layout pass of the pill); control show/hide skips when already at the target (also fixes a fast double-tap leaving the pills hidden); play/pause symbols cached; download progress hops to the main thread only when the integer % changes (was every chunk) and listener arrays are copied under the lock; "save/share all" reads disk-cached photos off the main thread with one shared `DispatchGroup`; **scrubbing the thumbnail strip no longer creates a player (and a network request) for every video page it passes** — the video starts when the strip settles; full screen detaches the hidden page `AVPlayerLayer` so one layer renders; close animation takes the current frame via VideoToolbox (native pixel format, no CoreImage pass) and skips the synchronous poster disk read; pull-to-dismiss no longer allocates a dynamic `UIColor` + array per pan event; periodic time observer removed in `deinit`.
|
|
414
|
+
- *Viewer video (Android)*: the 100 ms progress tick runs only while playing (it ran for the viewer's whole life, even on photo pages) and labels are set only when the second changes (`setText` on wrap_content = relayout); fade animations and ▶/❚❚ icon reloads skipped when unchanged; page switch sets the new `TextureView` before dropping the old one (one decoder output switch instead of two); viewers without any video keep the whole controls layer GONE; `MediaStore`/share copies use 256 KB buffers and stale `viewer_share/` copies older than a day are deleted; gallery → viewer builds its argument lists in one pass over the items.
|
|
415
|
+
- *Video editor / export (iOS)*: the editor and the bridge now compute the **same default state** (`VideoEditState.defaultState(for:options:)`) — before, a video whose short side was below `defaultQuality` was pre-encoded by the editor and then **re-encoded on send** because the bridge asked for a different state, and the prepared file leaked; prepared files are now also removed when the gallery returns (`releaseAfterReturn` → `discardAll`) and when a stale job is found at send; export progress updates the HUD at the throttled rate (was every frame → hundreds of main-queue blocks per second); the video cell reads duration/track size in the request callback instead of on the main thread; neighbour video pages no longer autoplay during a swipe; trim thumbnails are generated with one `generateCGImagesAsynchronously` request (forward decode reuse) instead of 12 independent seeks; the poster is re-uprighted only when the image changes; trim-handle drags don't re-trigger the pause animation; `VideoPrepareManager` cancels/deletes superseded jobs off the main thread; the encode loop wraps each frame in `autoreleasepool`; the hidden HUD spinner's infinite animation is removed while not shown.
|
|
416
|
+
- *Video editor / export (Android)*: trim seeks are de-duplicated and coalesced to one per frame; the position tick skips when paused and the trim strip redraws only when the playhead moved; `onDraw` reuses rect objects; editor players use a small `LoadControl` (neighbour pages prepared ~5 s instead of 50 s of read-ahead); thumbnail generation stops when the page moved on (no partial cache) and recycles full frames on API < 27; `preview` reuses one `MediaMetadataRetriever` for metadata + poster and posters/thumbnails decode at ≤ 512 px via `getScaledFrameAtTime` (API 27+); `takeReady` reports progress only when it changed; the export HUD pauses its animator while the window is hidden and allocates nothing per frame.
|
|
417
|
+
- *Camera cell (iOS)*: frames are not converted to `CGImage` while the restart cover is still up (they were discarded on the main thread anyway); the last-photo brightness and blur are cached per image (recomputed on every cell rebind before); the 0.1 s visibility timer gets a 30 ms tolerance; `switchToWideAngle` no longer opens a device input on the "already wide → zoom only" path; the settled-check is computed only for timing logs; the picker cell measures the duration badge only when shown; the layout asks "is item 0 the camera cell" once instead of per item.
|
|
418
|
+
- *Camera cell (Android)*: the reveal brightness check reads an **8×8 bitmap straight from the `TextureView`** (was two view-size bitmap copies + a software matrix draw every 30 ms); the full-screen camera snapshots only when a drag really starts (was on every touch down, ~20 MB each); pull-to-dismiss "peek" turns the camera off 0.3 s later so repeated pull-and-release doesn't open/close it each time; duplicate `requestLayout` per animation frame removed.
|
|
419
|
+
- Verified after the changes: iOS simulator (viewer play, full screen in/out, thumbnail scrub → plays only where it stops, editor trim strip + send) and S23 (viewer play/end/pull-close, gallery → viewer, editor trim drag). Not changed on purpose (would alter output/behaviour): audio pass-through, 420f camera format, saved-photo re-encode, streaming + cache download of the same remote video.
|
|
420
|
+
- **Media gallery grid lines are as thin as the picker's on Android**: the default cell inset was 2dp per side (4dp between cells); it is now 0.5dp per side like `item_image_grid` (≈1dp between cells). iOS already used 1pt. An explicit `spacing` option still wins.
|
|
421
|
+
- **Image viewer video — V4 (iOS + Android)**: **remote video cache** — `ViewerVideo.cacheFile` / gallery `videoCacheFile`: if the file is missing, the video streams and is downloaded there in the background (`.part` then rename, one download per URL), so the next open plays the file; without `cacheFile`, videos downloaded for save/share go to `Caches/RNAttachmentKit/viewer_video/<hash>.mp4` (Android `cache/RNAttachmentKit/viewer_video/`) and are reused for playback (iOS `ViewerVideoFiles` URLSession download delegate, Android `ViewerVideoFiles` HttpURLConnection on a 2-thread pool; HLS `.m3u8` is never downloaded). **Save / share handle videos**: the top-bar save on a video page saves the **video** to the photo library (iOS `PHAssetCreationRequest` `.video` resource from the file; Android `MediaStore.Video`, `Movies/`), share sends the mp4 (iOS file URL in `UIActivityViewController`; Android copied into `<external files>/Pictures/viewer_share/` for the FileProvider, mime `video/mp4`, or `*/*` when mixed with photos); "save/share all" mixes photos and videos in page order; a remote video is downloaded first with a small "동영상 받는 중 42%" label (`ViewerDownloadHUD` / `ViewerDownloadHud`); the group chooser says "이 동영상만 저장/공유" on a video page; toasts "동영상이 저장되었습니다" / "N개 항목이 저장되었습니다" / "동영상 저장에 실패했습니다". **Media gallery grid**: video cells show the picker's "▶ 0:12" badge (bottom-left, from `duration`; ▶ only when unknown). Demo data source: `dataSourceParams: { videos: true }` makes every 5th item a video (default unchanged); harness entries `-mediaGalleryVideoTest` (iOS launch argument) / `--ez mediaGalleryVideoTest true` (Android). New strings `video_saved`, `video_save_failed`, `items_saved_count`, `video_downloading`, `save_this_video`, `share_this_video` (59 languages; Android `turbo_viewer_*`). README: `openViewer` → Videos, `MediaGalleryItem` video fields, cache table. Verified on the iOS simulator: "Save This Video Only" → downloaded (~1 MB, cached under `viewer_video/`) → saved as `IMG_0017.MP4`; gallery grid badges "▶ 0:10" and tapping one plays it in the viewer. Verified on the S23 too: gallery grid badges, tapping a video cell plays it with the progress/⚙/⛶ pills, ⚙ menu "재생 속도" → 2x, ⛶ rotated full screen, close button and pull-to-dismiss close cleanly, save → `Movies/VID_….mp4` (991,017 bytes).
|
|
422
|
+
- **Image viewer video — V2 + V3 (iOS + Android)**: video pages get a **progress pill** above the counter/thumbnail strip (`0:03 ━━●── 0:10`, `m:ss` / `h:mm:ss`, updated every 0.1 s; dragging pauses, seeks — Android `CLOSEST_SYNC` while dragging, exact on release — and resumes only if it was playing) and an **options pill** below it with ⚙ and ⛶ (44 tall, same translucent grey as the text-entry capsules; iOS 26 Liquid Glass). The pills show with the bars and hide with them (tap / 3 s auto-hide while playing / pull-to-dismiss); the centre button keeps its V1 rule. **⚙ → playback speed** menu "재생 속도" `2x / 1.5x / 보통 / 0.5x` with ✓ on the current value (iOS: native `UIMenu` on the button; Android: an in-layout dark panel, radius 20, rows 48dp, tap outside closes); the speed stays for other videos while the viewer is open and resets when it is reopened (iOS `rate`/`defaultRate`, Android `setPlaybackSpeed`). **⛶ full screen** (landscape videos only): the app's orientation is never changed (ZemTalk may be portrait-locked, and rotating the RN host would relayout it) — instead a black full-screen layer shows the video and the same controls **rotated 90°** to fill the screen; the direction follows how the phone is held, flipping it to the other side turns the layer, standing it up (after it was held sideways) or ⛶ again / Android back exits; status bar (and Android navigation bar) hidden, pull-to-dismiss disabled. iOS: `ViewerVideoControlsView`, `ViewerScrubSlider`, `ViewerVideoFullscreenView` (an `AVPlayerLayer`-backed view, so the video follows the rotation animation), second layer on the same `AVPlayer`; `prefersStatusBarHidden` + `modalPresentationCapturesStatusBarAppearance`. Android: `ViewerVideoController` controls, full-screen `TextureView` (the player output moves there and back), `OrientationEventListener`, `WindowInsetsControllerCompat`. **Close animation uses the current frame** where the video really is on screen instead of the poster (iOS `AVPlayerItemVideoOutput` → `videoCloseSnapshot(in:)` used by `ZoomTransitionAnimator`; Android `TextureView.getBitmap()` + the video rect → `playCloseAnimation(…, overrideBaseRect)`). Fixed on iOS 26: buttons inside a glass `UIVisualEffectView.contentView` never received taps — pills are now a plain container with the glass background behind the buttons. New icons `ic_viewer_settings`, `ic_viewer_fullscreen(_exit)`, `ic_viewer_check`, `bg_viewer_pill`. Verified on the iOS simulator: progress, drag-seek, speed menu (2x → `rate 2.000`), full screen in/out with status bar hidden and auto-hide, close from a pull uses the current frame (640×360 at the dragged position). Android verified later on the S23 (see V4 entry).
|
|
423
|
+
- **Sending progress as events (iOS + Android), so videos fit the same flow as grouped photos**: right after Send and before any processing, the module emits **`onProcessingStart`** `{ total, items }`. Each item carries `index`, `mediaType` (`'image' | 'video'`), `originalUri`, and the expected output `width` / `height` (photos: resize rule; videos: quality, size cap and crop, from the new `VideoExporter.predictedOutputSize` on iOS and `VideoExporter.preview` on Android). Videos also carry `duration` (after trim) and a poster `thumbnailUri` jpg (iOS: crop and drawing applied; Android: first frame of the trim). The app can create every bubble at once, draw a ring from **`onVideoProgress`** (now throttled to 1% / 0.1 s), and fill each bubble from **`onImageProcessed`** (same `index`). The full-screen video HUD is optional: `video.showProgressHUD` defaults to `true`, but `false` when `asyncProcessing: true` (event-driven apps such as ZemTalk). Without the HUD, the app cancels with the new **`AttachmentKit.cancelVideoProcessing(index?)`** (no index = all remaining videos): that video ends as `{ error: 'CANCELLED', cancelled: true }` in `onImageProcessed` and the result, and the others keep going (HUD cancel still rejects everything, as before). New TS types `ProcessingStartEvent` / `ProcessingStartItem`, `ImageResult.cancelled`, `GalleryOptions.onProcessingStart`, and `VideoOptions.showProgressHUD`; spec method `cancelVideoProcessing(index: number)`. Shared Android helpers are in `MediaProcessing.kt` (`ProcessingJob`, `ProgressThrottle`). Checked: iOS bridge typecheck against the example app's Pods, and Android library Kotlin compile. Not checked: a full RN app run (needs a Dist build); the Android bridge files (newarch/oldarch) only compile inside an RN app.
|
|
424
|
+
- **Image viewer plays videos — V1 (iOS + Android; see `RNAttachmentKit_뷰어_동영상_작업지시서.md`)**: `openViewer({ images, videos })` takes a parallel `videos` array (`{ uri, cacheFile?, duration? }` or `null` for a photo page); for a video page `images[i]` is its poster. Media gallery items can be videos too (`mediaType: "video"`, `videoUri`, `videoCacheFile`, `duration`; `imageUri` = poster). Only the overlay viewers play video (iOS `RemoteImageViewerViewController`, Android `ImageViewerOverlayView`; ZemTalk uses `useOverlayViewer: true`) — the other Android viewers keep showing the poster. One player per viewer, attached to the current page: a video page autoplays from the start, leaving it pauses, no loop — at the end it pauses and shows ▶ + the bars again, ▶ restarts. Centre button (64 pt/dp glass/translucent circle: ▶ / ❚❚ / spinner while buffering) is shown while the bars are visible or the video is paused; tap toggles the bars, which hide 3 s after playing. Video pages don't zoom (Android `ZoomableImageView.isZoomEnabled`, taps react without the double-tap wait). Pull-to-dismiss hides the button and moves only the video (the poster is hidden once the first frame is drawn; iOS covers the poster rect in black); the close animation still uses the poster. Load failure → toast `viewer_video_load_failed` and ▶ retries. Android: `ViewerVideoController` (ExoPlayer + `TextureView` in an `AspectRatioFrameLayout`, audio focus, pauses when the host activity pauses), new dependency `media3-exoplayer-hls` (both gradle files) so HLS plays like on iOS; bridge args `videoUris`/`videoCacheFiles`. iOS: `ViewerVideoSupport.swift` (`ViewerVideoItem`, `ViewerVideoItemView`, `ViewerPlayButton`). Harness "원격 캐시 테스트": pages 2 (MP4) and 4 (HLS) are videos (Android harness now opens the overlay viewer). Harness result list (결과 이미지 Zoom/Fade/Slide): picked videos now play in the viewer — the converted mp4 is passed as that page's video (iOS `currentDisplayVideos`; Android `ResultImageInfo.videoPath`, opens the overlay viewer when a video is in the list); "멀티+편집" on iOS now converts picked videos too. Verified on the iOS simulator and the S23: autoplay, end pause + ▶ restart, HLS, tap toggle, pull hides the button / cancel restores / pull closes. New strings `viewer_playback_speed`, `viewer_speed_normal`, `viewer_video_load_failed` (59 languages; speed is for V3). Not yet: progress bar (V2), speed + full screen (V3), cacheFile download, save/share as video, grid duration badge (V4).
|
|
425
|
+
- **Video drawing / stickers / text (iOS, P5)**: the draw button in the video editor toolbar is enabled. It opens a new shared paint screen, `MediaPaintViewController` (bottom `[✕] [Draw | Sticker | Text] [✓]`, Telegram style), on the current frame with the crop applied, in the same spot as the video. It reuses the photo editor parts: `DrawingCanvasView` (pen + eraser; the eraser button sits left of the colour palette), the left brush-size slider, the same palette and saved colour/size (`DrawingViewController.paletteColors`, `drawing_colorIndex`, `drawing_brushSize`), `EmojiPickerViewController` (Sticker tab), `TextInputViewController` + `TextStickerView` (Text tab; the palette recolours the selected text), and the `EditorBubble` ✕/✓ (✓ only when something changed). Undo/redo/trash are in the top bar. ✓ returns one transparent overlay image in the cropped frame (cropped size, long side ≤ 1920), stored in `VideoEditState.overlayImage`. Like the photo editor, stickers and text are merged into that image on ✓; opening the screen again shows the previous drawing as the canvas base (`DrawingCanvasView.baseImage`, which the eraser and trash can remove). Preview: an overlay image view over the video cell. Export: when an overlay exists, every frame is composited with CoreImage into a pixel-buffer-pool buffer (`AVAssetWriterInputPixelBufferAdaptor`, no colour management, so the rest of the frame keeps its colours); overlays are never passed through. Re-cropping moves the overlay with the video (`VideoEditState.remappedOverlay(from:displaySize:)`), and the editor open/close animation includes it (`applyEdits(to:)`). New strings `paint_draw`, `paint_sticker` (59 languages). Verified on the simulator: pen + text + emoji → preview, then rotate crop → the drawing turns with the video, and the encoded MP4 (1280×718) has the drawing on every frame. Not yet: Android, mosaic/blur for video (they need per-frame pixels), and editing earlier stickers again (they are merged, as in the photo editor).
|
|
426
|
+
- **Video crop (iOS, P4 part 1)**: the crop button in the video editor toolbar is enabled. It opens the same crop screen as photos (ratio presets, 90° rotate, flip) on the frame at the current playhead (`AVAssetImageGenerator`, original orientation); reopening restores the previous crop. ✓ returns crop parameters instead of a cropped image (`CropViewController.onCropParametersComplete`, `initialRotations` / `initialFlipH` / `initialNormalizedCrop`; ✓ is always enabled in this mode so a crop can be undone). `VideoEditState` gains `cropRotations` (clockwise 90° steps), `cropFlipH`, `cropX/Y/W/H` (0–1 in the rotated/flipped frame) and `cropGeometry(displaySize:)`, shared by the preview and the encoder. Preview: the video cell is sized to the cropped aspect and the player/poster layers get the same rotate → flip → crop transform. Export: the transform is added to the existing `AVMutableVideoComposition` (after `preferredTransform`) and the render size is the cropped size at the chosen quality scale; cropped videos are never passed through. Quality panel size shows the cropped size. Verified on the simulator: rotate + 1:1 → preview and the encoded MP4 (720×718) match the crop screen. Editor open/close animation for a cropped video uses the cropped frame and aspect (`VideoEditState.applyCrop(to:)` / `croppedAspect(sourceAspect:)` in `ImageEditorTransitionAnimator`) — it used to animate the original and switch to the crop at the end; verified on the simulator (open and close). Not yet: angle slider (the shared crop screen has none yet), Android, the gallery thumbnail of a cropped video (still the original).
|
|
427
|
+
- Video editor page (iOS): fixed a strip of the poster image sometimes showing at the edge of the video (e.g. ~1 cm on the left). The video cell is now sized by the real video display size (track `naturalSize` with `preferredTransform`) instead of the poster's aspect, and the player layer is resized whenever the cell recomputes its frame (`updateImageAndStickerFrame` override) — before, a poster swap (low → high quality) resized the poster but left the player at its old size until the next layout pass.
|
|
428
|
+
- Video editor page (iOS): the whole video is shown, aspect-fit between the top bar and the trim strip (`EditorVideoPageCell.contentArea` — below the 52 pt top bar, above toolbar 56 + gap 12 + trim strip 44, 8 pt margins); it used to be fitted to the full screen and covered by the bars. Play button centred in that area, mute button at its top-left. The editor open animation targets the same area for video pages so it no longer jumps at the end (close already used the on-screen frame).
|
|
429
|
+
- Camera cell shows the last photo also when the gallery first opens (even after an app restart) and switches to live the same way. The last photo is kept across galleries: Android `CameraLastFrame` (memory, refreshed every 1 s while live — the window is already gone when the gallery closes — and saved on `onDestroyView` to `cacheDir/RNAttachmentKitCamera/last_frame.jpg`, 480 px); iOS saved on gallery dismiss to `Library/Caches/RNAttachmentKit/Camera/last_frame.jpg` and laid over the cell before the first session starts (preview layer inserted below it). Verified on the S23: open after force-stop → last photo during the sheet slide-in → live after ~0.9 s, no dark frames.
|
|
430
|
+
- iOS camera cell: the switch to live is stricter to avoid the slightly dark moment when scrolling back — live brightness ≥ 85% of the last photo, `exposureTargetOffset` within ±0.2 EV (exposure can stop "adjusting" before it reaches the target), 3 frames in a row. The last photo is now converted with VideoToolbox (`VTCreateCGImageFromCVPixelBuffer`) instead of CIContext, which could make it a bit darker than the live preview.
|
|
431
|
+
- Camera cell restart, fixed on the S23 with logs (`adb logcat -s CameraCell`) and frame-by-frame screen recordings: (1) scrolling back up bound CameraX twice (grid rebind + visibility both asked) — the 2nd bind restarted the just-opened camera; now a pending bind for the same `PreviewView` is not repeated; (2) a later grid rebind treated the camera as "already live" and removed the last photo immediately, showing the dark/blue first frames — now the last photo is only removed after the live picture is ready; (3) "ready" now uses the camera's own 3A state instead of comparing frame colours (which never settled while hand-held → always waited the full timeout): Android Camera2Interop `CONTROL_AE_STATE`/`CONTROL_AWB_STATE` converged + preview brightness ≥ 60% of the last photo (max 1.2 s); iOS `isAdjustingExposure`/`isAdjustingWhiteBalance` false + 2 bright frames; iOS also ignores a second start while a restart is in progress (it used to fade the photo out right away). Front/back switch and flash in the full-screen camera always rebind (`force`). Result on the S23: editor round trip and scroll back both go last photo → live after ~0.75–1 s with no black/blue frames.
|
|
432
|
+
- Video editor page (iOS): the top bar is the same as the photo editor (back, counter, send — send goes through `performApply`, which already handles video); the bottom video toolbar no longer has its own back/send buttons (crop, draw, quality only), and the mute button sits below the top bar.
|
|
433
|
+
- Gallery camera cell turns the camera off when it isn't seen and back on smoothly. Off: a photo/video editor (or another full-screen screen) is over the gallery, or the camera cell is scrolled fully out of view → the current frame is laid over the cell as the "last photo" and the session/CameraX is released. On: when visible again the camera restarts behind that photo, and the photo fades out only once the live picture is really showing — the first frames after a restart are black/dark while exposure settles, so the module samples the live frames (8×8 grid) and waits until their brightness reaches 60% of the last photo's (iOS: video data frames + 0.08 s, max 1 s, fade 0.25 s; Android: `PreviewView.bitmap` every 50 ms after STREAMING, max 0.8 s, fade 0.3 s). This removes the black flash right after the switch. The fade also waits until the colour is stable (average RGB of consecutive samples within ±3 — iOS 3 frames, Android 2 samples; max 1.2 s), since white balance settles after exposure and the first frames look bluish. The "last photo" is now only taken from a live, settled stream: Android no longer re-captures it in `onViewDetachedFromWindow` after the camera was already stopped (that saved a black/blue frame, shown first when scrolling back up), and iOS no longer stores frames from a restart that is still settling. iOS: handled inside `CameraPreviewView` (checks every 0.2 s for a covering presentation over the gallery or its navigation controller, and whether it intersects its scroll view) — no gallery changes, works for every editor path; the full-screen camera overlay keeps running. Android: `ImagePickerBottomSheet` pauses on `onPause` (the editor Activity is translucent, so the gallery was only paused and CameraX kept running) and resumes on `onResume`; scroll visibility checked in the grid's `onScrolled`.
|
|
434
|
+
- Photo editor text entry: the top **Cancel / Confirm are glass capsules** (44 pt/dp tall, 16 side padding) instead of plain text — iOS 26 uses the system Liquid Glass button (`UIButton.Configuration.glass()`), older iOS and Android a translucent grey capsule with a hairline border (`bg_text_input_capsule`).
|
|
435
|
+
- iOS camera cell: the "last photo" cover shown while the camera is off / turning back on is now **lightly blurred like frosted glass, without darkening** — the last camera frame itself is Gaussian-blurred (downscaled to 360 px, σ 4, CoreImage) instead of overlaying a `UIBlurEffect`, which always adds a tint (dark grey in dark mode). The frame is the one the video output keeps fresh (≤0.33 s old); if none exists yet, a sharp screen snapshot is used. The whole cover still fades into the sharp live picture; the cover used while re-parenting into/out of the full-screen camera stays sharp. Verified on the iPhone 15 (`-cameraTiming` also saves `cam_cover_src.jpg` / `cam_cover_blur.jpg`): blurred cover average brightness 152 vs 141 for the source (not darker), resume → live still ~0.33 s.
|
|
436
|
+
- **Video page layout aligned with iOS on Android**: the photo editor's top bar stays on video pages (‹ · N / M · ✓ send / selection badge), the video is fitted between the top bar (52dp) and the trim strip + video bar (112dp) via page padding (mute at the area's top-left, ▶ centred in it), and the bottom video bar is `[crop] [draw] … [quality]` — back/send live in the top bar (the old bottom buttons are kept hidden).
|
|
437
|
+
- **Pull-to-dismiss on video pages shows only the video** (Android + iOS): Android makes the video page/player backgrounds transparent and slides the trim strip and quality panel away with the bars (`EditorVideoPageFragment.setPullMode`); both platforms hide the mute and ▶ buttons while pulling and bring them back if the pull is cancelled (iOS `EditorVideoPageCell.setPullMode`).
|
|
438
|
+
- **Pulling an editor down turns the gallery camera back on immediately** (iOS + Android): as soon as the pull-to-dismiss drag starts in the photo/video editor (the gallery behind starts to show), the camera cell resumes live; if the editor springs back without closing, it pauses again. iOS: `CameraPreviewView.setEditorPeeking(_:)` from the editor's `beginPullToDismiss`/`cancelPullToDismiss` (and `viewDidDisappear`), which overrides the "covered by a presentation" check. Android: `TransitionHelper.onEditorPeek` from `ImageEditorActivity` → `resumeCamera/pauseCamera("lifecycle")` in the sheet (CameraX runs while the gallery is only paused under the translucent editor). Verified on the S23: drag start → live in ~0.5 s, release → paused.
|
|
439
|
+
- Android full-screen camera: **no more black flash when pulled down to close** — screen recording showed the close animation restarted the dim background and buttons at full opacity (one fully black frame) and fought a parallel "scale back to full size" animation. The shrink now starts from the camera's current on-screen position/size and the current opacities and goes straight into the cell. The pull also **follows the finger sideways** (like the editors and iOS); closing is still decided by the downward distance.
|
|
440
|
+
- Android camera cell: the "last photo" cover shown while the camera is off / turning on is now **blurred** (`CameraCoverBlur`: `RenderEffect` blur on Android 12+, a 1/14-scale bitmap stretched with filtering below), and fades into the sharp live picture — like Telegram/iOS. The short cover used when returning from the full-screen camera stays sharp (the camera is already live there).
|
|
441
|
+
- **Android camera cell switches to live faster** (measured on the S23 with `adb logcat -s CameraCell`): the last photo no longer waits for Camera2's AE/AWB "converged" signal, which arrived 250–500 ms after streaming started even though the picture was already bright. It now samples the drawn preview every 30 ms and reveals as soon as two samples in a row reach 70% of the last photo's brightness (or one, if 3A already converged); fade 300 → 220 ms. Result: bind → live **516–555 ms** (was 736–1008 ms), of which ~470 ms is CameraX opening the camera. The reveal log now includes the sampled brightness trace.
|
|
442
|
+
- **iOS camera cell: the real cause of the slow switch to live, measured on an iPhone 15** (harness `-cameraTiming -cameraResumeTest` writes `Caches/cam_timing.log`; pull with `devicectl device copy from`): the `AVCaptureVideoDataOutput` sample-buffer delegate proxy was never retained (the output holds its delegate weakly), so **not a single frame ever reached the "is the live picture ready?" check** and every switch waited for the fallback timeout (≈1.8 s after scrolling back, ≈1.0–1.8 s on first open). The proxy is now kept in `sampleBufferProxy`. With frames arriving, the readiness rules were tuned on the device logs: after a locked-exposure resume only the 2–3 dark sensor-warm-up frames (luma ~25) are skipped (≥75% of the target), and on a first start the picture is accepted from the 3rd frame when bright enough and within 0.8 EV of the exposure target instead of waiting for AE to report fully settled (~1 s). Result on the iPhone 15: scroll back → live in **~0.31–0.34 s** (of which ~0.2 s is `startRunning`), first open **~0.4 s** (was 1.0–1.8 s). The live-frame "last photo" is also updated again (it relied on the same frames).
|
|
443
|
+
- iOS camera cell comes back to live much faster: right before the camera is paused, exposure / white balance / focus are locked at their current values (`lockImagingForResume`), so after the restart the very first frame already matches the cover photo — one bright frame (+0.04 s) is enough instead of waiting for AE/AWB to settle (3 frames, often ~1 s). Auto modes are restored right after the switch (or after 0.5 s if the scene changed too much, then the old criteria apply). Reopening the gallery in the same app run benefits too (the device keeps the locked values from the last close). Visibility check 0.2 → 0.1 s, cover fade 0.3 → 0.22 s.
|
|
444
|
+
- iOS camera cell, off/on transition reworked to be seamless: when the camera is paused (scrolled away, an editor over the gallery, gallery closing) the cell is now covered with an **exact render-server snapshot of what is on screen** (`snapshotView(afterScreenUpdates:false)`) instead of the last video-output photo, which was up to 0.33 s old and colour-processed differently — so nothing "jumps" at the moment the camera stops. Coming back, the cover fades (0.3 s, ease-in-out) only once the live picture is settled, as before (fallback 1.5 s). A first start without any last photo now also keeps the blur until the picture is settled (it used to drop the blur as soon as the session started, showing the black first frames). Opening/closing the full-screen camera, where the live view is re-parented between the cell and the overlay, now also covers the re-parent with a snapshot for a few frames (`makeTransitionCover()`).
|
|
445
|
+
- **Deselecting an edited video asks first**: a video whose quality/mute/trim differs from the default counts as "edited" like a photo — tapping the selection badge in the editor (or deselecting it in the grid) shows the existing "delete the edits?" alert, and Yes resets the video to the defaults (720p, sound on, full length), discards the pre-encoded file and restores the player/trim strip/pill. Returning a setting to its default by hand clears the "edited" flag (the state entry is removed on both platforms).
|
|
446
|
+
- **Video editor polish**: the video page no longer loops — it plays once, **auto-pauses at the end (or the trim end)** and shows a Telegram-style ▶ circle; tapping toggles play/pause and ▶ after the end restarts from the range start (iOS `AVPlayer` boundary/end observers, Android `REPEAT_MODE_OFF` + `STATE_ENDED`). Bottom bar icons use fixed point sizes (back 20, tools 22) and the send button is the same 44x36 accent pill as the AI chat composer. The **encoding HUD** keeps its progress indicator (ring on iOS, horizontal bar on Android) and adds an animated clapperboard icon (top board opens and snaps shut every 1.6 s), a frosted card, "64% · 1/3" and a capsule Cancel button.
|
|
447
|
+
- **Video editor page — Telegram layout + trim (P3) + pre-encoding (iOS + Android)**: the video page now has no top bar; it is player (mute toggle top-left) → **trim strip** (`VideoTrimView`: 12 thumbnails, accent handles, playhead, min 1 s, dragging a handle pauses and seeks, releasing saves `trimStart/End` and the player loops inside the range) → bottom bar `[←] [crop] [draw] [quality pill e.g. "720p"] [send]` (crop/draw are placeholders until P4/P5). Tapping the pill opens the **quality panel** (`Quality …… 360p` slider with ticks, `✕ │ MP4 • ~220 KB • 352x640 │ ✓`; ✕ reverts to the step the panel opened with). **No "Original" step** — steps are `video.qualities` ≤ the short side (smallest step if the source is smaller). Every change (quality/mute/trim) **starts encoding in the background right away** (`VideoPrepareManager`, 0.6 s debounce, replaces the previous job, output `turbo_video_prep_*`); on send the bridge/harness takes the ready file (or waits for the in-flight one) instead of encoding again — verified "미리준비됨 0 ms" on S23 and the simulator. Exporters: iOS `AVAssetReader.timeRange` / Android `ClippingConfiguration`; trimmed videos always re-encode (frame-accurate). New string `video_quality` (59 languages).
|
|
448
|
+
- **Video editor page — P2 (iOS + Android)**: tapping a video in the editor-enabled picker now opens the photo editor with a **video page** mixed in with photo pages (`EditorVideoPageCell` / `EditorVideoPageFragment`): auto-play + loop (`AVQueuePlayer`+`AVPlayerLooper` / `ExoPlayer`), tap = play/pause with a centre ▶, mute toggle (bottom-right; top-right is the selection badge). On a video page the tool row is replaced by a **quality slider** (`VideoQualityBar` / `videoQualityBar`: "Smaller size" ↔ "Better quality", ticks = `video.qualities` below the source's short side + "Original", steps over `maxFileSizeMB` are greyed/unselectable) and the top-bar counter becomes the info line `720x1280 · 0:06 · ~2.6 MB` (estimate from the bitrate table, real file size for "Original"). Changing quality/mute auto-selects the page like other edits. The state (`VideoEditState{qualityHeight?, muted}`) lives per asset in the gallery (`GalleryViewController.videoEditStates`) / sheet (`ImagePickerBottomSheet.videoEditStates`, shared with the editor through `EditorDataHolder`) and is read by the bridge on send (multi send, single-edit send — new `exportSingleVideo` on iOS — and the harness). **Quality steps now mean the short side** on both platforms (portrait 1080x1920 at "720p" → 720x1280, like Telegram; was the height → 405x720). Android `VideoEditState.qualityHeight` is nullable (null = original). New strings `video_quality_smaller`, `video_quality_better`, `video_original` (59 languages). `TurboImagePickerConfig.videoOptions` carries the RN `video` options to the Android editor. Verified: iOS simulator and S23 — 1080x1920 rotated source at 360p + mute → 360x640 H.264 without an audio track (0.27 MB iOS / 0.69 MB Android). Not yet: trim (P3), crop (P4), draw/sticker/text (P5), `openEditor` for videos (P6).
|
|
449
|
+
- **Video in the picker — P1 (iOS only so far; see `RNAttachmentKit_동영상_지원_작업지시서.md`)**: `openGallery({ mediaType: "all" | "video", video?: VideoOptions })` lists videos mixed with photos by date (default `"photo"` = unchanged). Video cells show a Telegram-style "▶ 0:04" duration badge; the Videos smart album appears; tapping a video toggles selection (no video editor yet) and videos longer than `video.maxDurationSec` (default 10 min) show `video_too_long`. On send each video goes through the new `VideoExporter` (framework, public): remux to mp4 without re-encoding when there are no edits, the quality is "original" and the source is H.264; otherwise `AVAssetReader`/`AVAssetWriter` H.264/AAC transcode at `video.defaultQuality` (default 720p; bitrate table / 30fps cap ≤720p / size cap `maxFileSizeMB` default 100 pick a lower step), rotation baked into pixels, fail → one retry one step lower → copy original. A window-level `VideoExportHUD` shows progress with Cancel (cancel rejects with `CANCELLED` and deletes files). Results carry `mediaType: "video"`, `duration`, `thumbnailUri` (jpg ≤512), `videoCodec`, `videoBitrate`, `edited`; `onImageProcessed` fires for videos too and `onVideoProgress {index,total,progress}` (JS `video.onProgress`) during conversion. Output `Caches/RNAttachmentKit/turbo_video_*` is cleaned after 24 h. TS: `VideoOptions`, `VideoProgressEvent`, new `ImageResult` fields. Strings `video_too_long`, `video_too_large`, `video_encoding`, `video_encode_failed` (59 languages). Harness opens with `mediaType: "all"` and prints export results.
|
|
450
|
+
- **Android P1** (same RN API): `ImagePickerBottomSheet` / `AlbumPickerDialog` query `MediaStore.Files` with `MEDIA_TYPE IN (image, video)` when `mediaType` ≠ `"photo"` (video uris are `…/video/media/<id>`, so `loadThumbnail` / Glide / `openInputStream` work unchanged; API < 29 uses `Video.Thumbnails`), `ImageWithDate` carries `isVideo` / `durationMs`, the grid shows the "▶ 0:04" badge (`item_image_grid.xml` `durationBadge`), tapping a video toggles selection, too-long videos toast `turbo_video_too_long`, the initial-list cache is keyed by `mediaType` and the MediaStore observer also watches videos. Bridge (newarch + oldarch): parses `mediaType` / `video`, asks `READ_MEDIA_VIDEO` with `READ_MEDIA_IMAGES` on Android 13+ (14+ partial access counts as granted), passes `mediaType` / `maxVideoDurationSec` to the sheet, and `processImages` sends videos through the new `VideoExporter.kt` (media3 Transformer 1.4.1: H.264/AAC, `Presentation.createForHeight`, bitrate table, passthrough copy for H.264 ≤ target, fail → one step lower → copy original, size cap retry, rotation baked, thumbnail jpg ≤512) with a `VideoExportHUD` dialog (progress + Cancel → reject `CANCELLED`, files deleted) and `onVideoProgress`. Android does not lower fps (media3 1.4 has no simple frame-drop effect) — only the ×1.5 bitrate rule applies. `onSelectionChange` now reports the real `maxSelection` (was hard-coded 10). Manifests declare `READ_MEDIA_VIDEO`; media3 deps added to both `android/rnattachmentkit/build.gradle` and `android/build.gradle`; `TempFileCleaner` also removes `turbo_video_*`. Verified on S23: 360p/720p H.264 → passthrough ~80 ms, 1080p → 720p transcode ~1 s (2.3 MB, 3.2 Mbps), 90°-rotated 1080p → 406x720 upright.
|
|
451
|
+
- AI image screen follows the module language like the other screens (theme already did). Android `AIEditActivity` now applies `TurboImagePickerConfig` language in `attachBaseContext` (was the device language), so texts, country names, example list and "Recent works" title use the `openGallery` / `setDefaults` languageCode; the default nationality still comes from the device region. Menu/option names (age, style, framing, quality…, scene categories, "Glamour", edit categories and edit actions — 200 labels) are now translated into all 59 languages through `scripts/i18n/strings.json` (`ail_*` keys; iOS `Localizer`, Android `turbo_ail_*` resources kept from resource shrinking by `res/raw/turbo_keep_ail.xml`). Korean keeps the original Korean labels; example prompt texts stay Korean/English.
|
|
452
|
+
- **AI generation screen reorganized** so the two ways to create are clearly separate:
|
|
453
|
+
- **Top — create it yourself**: gender/country → style/options/scenes → ratio chips → a large **Create** button (shows the per-use price for point users, e.g. "Create · 200 P").
|
|
454
|
+
- **Middle**: the image (or the sparkle + hint while empty) and the thumbnails of images made in this session.
|
|
455
|
+
- **Bottom — "Quick create from samples"** (title, "Tap to create right away", refresh button) with the example chips. Tapping a sample **always asks first** with an alert showing what will be charged ("200 P will be deducted (you have 400 P)" / "Uses 1 free use (27 left)"); with too few points it goes straight to the top-up alert. Hidden while an image is shown, dimmed while generating.
|
|
456
|
+
- **Top bar is just the title**: "AI image" (generate) / "AI edit" (edit). The balance moved to a **status line at the bottom** (above the chat box; at the very bottom while viewing an image; above the suggestion chips in edit mode): point users "Ⓟ 400 P · Standard 200 P · High quality 300 P" (balance red when short), subscribers "You can use AI N more times free this month".
|
|
457
|
+
- **Recent works**: every image generated on the AI generation screen is kept on the device (iOS `Application Support/RNAttachmentKit/AIRecent`, Android `filesDir/RNAttachmentKitAIRecent`, JPEG, newest 300). When there are any, a **history button** appears on the right of the top bar and opens them in the existing **Media gallery** ("Recent", grouped by date) — the viewer's Save / Share / Delete work directly (Delete also removes the file). iOS `AIRecentWorks` + `MediaGalleryViewController`, Android `AIRecentWorks` + `MediaGalleryOverlay` (back closes the gallery first). `ImageViewerOverlayView` now registers its back callback only when the host is a `ComponentActivity`, so the viewer also opens over plain Activities.
|
|
458
|
+
- New strings `ai_generate_title`, `ai_edit_title`, `ai_sample_title`, `ai_sample_hint`, `ai_confirm_points`, `ai_confirm_free`, `ai_recent` (59 languages).
|
|
459
|
+
- Fix: AI screens ignored the RN appearance and followed the system (iOS: the gallery sets the RN appearance on its navigation controller, the AI screen copied the gallery VC's own unspecified style → now the presenter passes the style it actually shows; Android: when appearance is "system", the app's AppCompat default night mode is checked before the device setting). Light mode also gets dark status-bar text on iOS (`preferredStatusBarStyle`, captured over `.overFullScreen`). Order everywhere: RN appearance → otherwise the app/system. Test harness: launch argument `-appearanceLight` / `-appearanceDark` forces the gallery like RN does.
|
|
460
|
+
- AI screens (generate + edit) follow **light / dark** like the other screens: RN `appearance` ("system" → device setting) via iOS `overrideUserInterfaceStyle` / Android `TurboImagePickerConfig.appearance`. Shared palette `AITheme` (iOS + Android): background, text, chips, pills, chat box, ratio chips, thumbnails, refresh/cancel buttons, keyboard, status/navigation bar icons, dialogs and popup menus; in light mode the sparkles are drawn in the theme color. White text on theme-color buttons and dark overlays on photos stay the same in both modes.
|
|
461
|
+
- AI billing info: **subscribers** (free uses left, e.g. 30 per month) see "You can use AI N more times free this month" in the middle of the screen (no top badge); **point users** keep the top balance badge and see "Standard 200 P · High quality 300 P" in the middle (generate: under the hint, edit: above the suggestion chips). The Quality option shows its price ("High (1K) · 300 P"). New strings `ai_free_center`, `ai_price_info` (59 languages).
|
|
462
|
+
- **RN AI integration**: `AttachmentKit.setAIConfig({ enabled, points, freeCount, costPerUse, highQualityCost?, endpoint?, headers?, apiKey? })` (only the passed values change; call again whenever the balance changes). AI is **off by default** — the gallery ✦ button and the editor AI tool appear only after `enabled: true` (and an endpoint/key). Each request uses the subscriber's free count first (e.g. 30 per month; the app/server decides), then points (`costPerUse`, or `highQualityCost` for Quality High); with too few points the user gets "Not enough points · Top up" → `onAIChargeRequired { kind, required, points, freeCount }` and the AI screen closes. Only successful requests are charged; the module lowers its local copy and emits `onAIUsage { kind, usedFreeCount, pointsDeducted, highQuality, points, freeCount }`. The AI screen title has a balance badge next to "AI" — a theme-color capsule with "🎁 N free left" or "Ⓟ points · cost per use" in bold (red when the points are not enough) — that updates live. Listeners: `addAIUsageListener`, `addAIChargeRequiredListener`. Native: iOS `AIBilling` (public) + bridge `setAIConfig`, Android `AIBilling` + `setAIConfig` in both bridge modules, TurboModule spec updated. Test harnesses turn AI on without charging. New strings `ai_free_left`, `ai_points_left`, `ai_charge_title`, `ai_charge_message`, `ai_charge` (59 languages).
|
|
463
|
+
- Image editor: 6th toolbar tool **AI** (rightmost; the drawing pencil with a small "AI", in the theme color). Opens the AI screen with the system's default transition (iOS full-screen modal; Android a separate activity like crop): the photo and a chat box (ZemTalk chat-input look).
|
|
464
|
+
- **Natural-language editing (paid, Gemini):** type any instruction in any language — "왼쪽 바위 지워줘", "배경을 노을 지는 바다로". Model `gemini-3.1-flash-image` by default (≈10–12 s, ~1.3 MP result). While it works: animated sparkles (three ✦ that breathe in turn, the big one slowly turning, glowing in the theme color) with no caption, a light sweep across the photo, and Cancel; the result becomes the new photo so you can keep editing; **Apply** replaces the photo in the editor like crop/drawing.
|
|
465
|
+
- Configuration: `AIGemini.endpoint` (+ `extraHeaders`) for production — the app server forwards the same `generateContent` body to Gemini with its key (billing/quota on the server); `AIGemini.apiKey` for testing only (never ship a key in the app). Test harnesses read the key from git-ignored files: Android `android/local.properties` `gemini.apiKey=`, iOS `ios/Secrets.xcconfig` `GEMINI_API_KEY =` (via `ios/Config.xcconfig` `#include?`; `build_xcframework.sh` forces it empty so it never reaches Dist). RN bridge for these settings: not yet.
|
|
466
|
+
- **AI image generation (paid, Gemini)** from the gallery: a ✦ button right of the X (theme color; hidden on the location/file tabs and when `AIGemini` is not configured) opens the AI screen in generate mode — empty state with sparkles and a hint, aspect-ratio chips above the chat box (1:1 default · 3:4 · 4:3 · 9:16 · 16:9 → Gemini `imageConfig.aspectRatio`; kept for follow-up instructions), the same chat box ("노을 지는 해변에서 뛰어노는 골든 리트리버"), further instructions refine the image, **Save** stores it in the device photo library (iOS Photos / Android `Pictures/`) and it appears first in the gallery (not auto-selected).
|
|
467
|
+
- Every Gemini request (edit and generate) gets a hidden quality instruction appended (`AIGemini.qualityGuard`): anatomically correct people — two arms/hands/legs, five fingers, no extra or merged limbs, natural face, no text/watermarks — in any art style. Set it to "" to turn off.
|
|
468
|
+
- AI generation screen options at the top: **Woman / Man** (default woman) and **country** (flag + localized name, ~46 countries; default = device region, else Korea). Both are remembered. The examples come from the chosen gender's lists (women: 68 fashion-editorial/snapshot + 30 general; men: 30 + 30), and the chosen nationality is written into the example prompt ("Korean woman" → "<Nationality> woman" + a nationality sentence). Typed prompts are sent as written.
|
|
469
|
+
- AI generation screen: 6 example chips per open (3 fashion-editorial + 3 general, random; all women in their early twenties), tapping one generates right away without filling the chat box. Source `scripts/ai_prompts/prompts.json` → `python3 scripts/ai_prompts/generate.py` (iOS `AIPromptExamples.swift`, Android `AIPromptExamples.kt`). Korean app → ko labels, others → en.
|
|
470
|
+
- iOS gallery: returning from a screen presented over it no longer resets the sheet to collapsed mode (list scroll was disabled when the sheet was expanded) and prefetching is re-enabled; the AI generation screen is presented `.overFullScreen` so the bottom attach menu stays above the sheet. The AI screen (edit and generate) no longer uses `keyboardLayoutGuide` for the chat box — it stayed pinned to the screen bottom while the screen slid down on close, so the sparkles/examples jumped to the top; the box now sits on the safe area and follows `keyboardWillChangeFrame`.
|
|
471
|
+
- **Gemini image size: 512 px by default**: `imageConfig.imageSize = "512"` on every request (`AIGemini.imageSize`; `edit(..., imageSize:)` per call), and the photo sent is downscaled to 512 px on the long side (was 1536). Before this no size was sent, so results came back at the 1K default. The generation screen's **Quality** option (Standard 512, default / High 1K) sends "1K" and a 1024 px input; 2K/4K are never used.
|
|
472
|
+
- AI generation screen, second row under gender/country (generate mode only, each choice remembered; labels ko/en like the examples):
|
|
473
|
+
- **Style** menu: Photo (default) · Film camera · Cartoon · Webtoon · Anime · 3D character · Watercolor · Oil painting · Sketch. Photo pushes photorealism unless the prompt explicitly asks for another art style; the illustration styles override photographic wording.
|
|
474
|
+
- **Options** menu (count of changed options on the button, "Reset to default"): Framing (auto / full body / upper body / face close-up / selfie / waist-up fashion / lower-body outfit / back view / side silhouette — random pose is skipped for the fixed framings, random expression for the lower-body outfit), Mood & light (auto / daylight / golden hour / night neon / mood lamp), Hair (auto / long straight / short bob / wavy), Body type (auto / very thin / slim / glamorous / chubby / plus-size — kept even when other text says "model figure"), Face (auto / visible / hidden by phone in a mirror selfie / mosaic / blur / sticker), Quality (Standard 512 / High 1K → `imageSize`, not a prompt; not remembered — every AI screen starts at Standard). The chosen items are appended as "additional settings" that take priority over the example text.
|
|
475
|
+
- Single source `scripts/ai_prompts/prompts.json` (`options`, `beauty`) → `AIPromptExamples.optionGroups` / `optionsPrompt(_:)` / `beauty`.
|
|
476
|
+
- AI generation screen: **Scenes** menu (third button in the second row): every example is listed by category — Daily life, Travel & nature, Sports & hobby, Fashion, Art styles, then a separate **Glamour** section (women: Pool & beach / At home / Selfie / Dress & night / Outdoor & travel; men: Pool & beach / Fitness / Style / Night & travel). Picking one enables the **Create** button above the ratio chips (disabled until a scene is chosen); Create always makes a new image (not an edit of the current one) with the chosen gender/country/options and a fresh random variation. Changing gender clears the choice (different lists). Source `prompts.json` `categories` (indexes into the example lists, checked to cover every example exactly once) → `AIPromptExamples.categories(languageCode:male:)`.
|
|
477
|
+
- Scenes greatly expanded (women 207, men 168): new categories Cafe & date, City night, Party & events, Work, Retro & concept, Fantasy & costume, Seasons & holidays; more Daily life / Travel / Sports & hobby / Fashion scenes; a new Glamour subcategory **Red carpet & luxury** (evening gowns/tuxedos — red carpet, opera, casino, limousine, penthouse, private jet, gala, Cannes, jewelry). One scene list builds both genders (`[women's outfit|men's outfit]`); every person is "adult". New items also join the random example chips.
|
|
478
|
+
- Options menu: a group set to anything other than its default is marked — iOS: theme-color filled check icon (gray circle when default); Android: "● " + the chosen value in bold theme color.
|
|
479
|
+
- AI generation screen: every image made in this session (Create, example chips, typed prompts and follow-up edits) is added as a thumbnail to the right of **Create**. Once an image is made the chat box and ratio chips hide and the image fills the screen; a **+** tile at the front of the thumbnail row brings back the default screen (examples, ratio, chat box) for a new image. Tap a thumbnail to show it large; the ✕ on each thumbnail deletes it (deleting the one being shown with none left brings back the default screen). **Save** stores all of them in the order they were made — iOS `AIEditViewController.onSaveAll` (gallery saves them one after another so the newest ends up first), Android result `EXTRA_RESULT_URIS` (all MediaStore URIs; `EXTRA_RESULT_URI` = the last one) inserted into the picker grid.
|
|
480
|
+
- Example chips in the middle now work exactly like **Create**: tapping one selects it as the scene (shown on the Scenes button) and generates a new image right away; pressing Create again makes another variation of the same scene.
|
|
481
|
+
- **Editor AI screen (edit mode) redesigned** like the generation screen — choose first, then apply:
|
|
482
|
+
- Top: Style · Options (only Mood & light / Hair / Face / Quality — age, framing, body type are generation-only) · **Edit** menu, focused on face and body: Retouch, **Skin** (blemishes, smooth, brighten, dark circles, redness, pores, glass skin, matte), **Makeup** (natural, glow, smoky, red/coral/MLBB lips, blush, no makeup), **Eyes & brows**, **Face shape** (slimmer face, V-line, cheekbones, double chin, nose, lips), **Body** (slimmer overall/waist/arms, longer legs, taller, shoulders, posture, toned), **Smile & teeth**, **Beauty light**, then Background, Outfit (incl. skinny jeans, mini skirt, crop top, bodycon dress, off-shoulder, leggings, slip dress), Hair, Season & weather, Sky, Remove, Food, Pet, Art style, Enhance. Categories that fit the photo come first.
|
|
483
|
+
- **Tapping a menu item or a chip only selects it** (one per category): the menu shows a theme-color check and the chosen item, the Edit button shows the count, chips turn solid theme color with ✓. **Apply edits** (left of the thumbnails) sends every selected edit + non-default options + whatever is typed in the chat box as one request, then clears the selections; sending from the chat box does the same (selected edits + the typed text).
|
|
484
|
+
- Under the photo: one row of suggestion chips from a **free on-device analysis** — iOS Vision (faces/people, image classification), Android ML Kit face detection + image labeling (Play-services versions `play-services-mlkit-face-detection` / `play-services-mlkit-image-labeling`, models downloaded by the device so the APK barely grows) → tags person / pet / food / scenery. (The Gemini-based "AI ideas" chip was removed on request; `AIGemini.analyze` + `AIPromptExamples.analysisPrompt` remain unused for later.)
|
|
485
|
+
- Thumbnails: **Original** first (labelled, not deletable) then every result; edits apply to the selected thumbnail; **Apply** (top right) sends the selected result back to the editor (hidden while Original is selected).
|
|
486
|
+
- Every edit request starts with an identity/composition-keeping instruction (`AIPromptExamples.editPrefix`) and ends with a real-person guard (`editGuard`: everyday fashion is fine, but never underwear/lingerie/swimwear, see-through clothing, nudity, enlarged chest/buttocks/hips, or suggestive poses). Style is appended only when it is not Photo.
|
|
487
|
+
- New strings `ai_edit_menu`, `ai_original`, `ai_apply_edit` (59 languages). Podspecs add `Vision`, `CryptoKit` (Dist podspec needs the same when deploying).
|
|
488
|
+
- **Safety blocks**: `AIGemini` now reports `PROHIBITED_CONTENT` / `SAFETY` / `IMAGE_SAFETY` / blocklist reasons (candidate `finishReason` or `promptFeedback.blockReason`) as a separate error (iOS `GeminiError.blocked`, Android `AIGemini.Blocked`). The screen then retries once without the random variation and with a modest-wording instruction (`AIPromptExamples.safeRetry`); if that is blocked too it shows a friendly message (`ai_blocked`) instead of the raw code.
|
|
489
|
+
- New strings `ai_situation`, `ai_situation_clear`, `ai_make`, `ai_blocked` (59 languages).
|
|
490
|
+
- Generated people look celebrity-level: every generate request now **starts** with a strong looks instruction (`AIPromptExamples.beauty`: famous top actress/actor or model, refined symmetrical face, large clear eyes, flawless skin, styled hair and makeup, magazine-cover quality, never ordinary-looking, natural features of the chosen nationality) and ends with a short reminder (`beautyTail`); the country sentence on examples reads "a strikingly beautiful <Nationality> woman/man with celebrity-level looks". (Before, the looks instruction was only appended at the end and had little effect.)
|
|
491
|
+
- Options → **Age** (first in the menu): Auto = early 20s (default) / early 20s / late 20s / early 30s / late 30s / 40s. The chosen age replaces "early twenties" in the example text and an age instruction is appended (also for typed prompts).
|
|
492
|
+
- New strings `ai_options`, `ai_options_reset` (59 languages); the short-lived `ai_style_*` strings were removed (style labels come from `prompts.json`).
|
|
493
|
+
- AI generation screen: a refresh button under the example chips draws a new random set each tap (symmetric two-arrow icon so it sits centered in the circle: iOS `arrow.triangle.2.circlepath`, Android `ic_ai_refresh` = Material "sync"). 18 more women's examples in a casual smartphone-snapshot style (mirror selfies, on the bed, back/side poses, street, gym, seaside) → 68 featured.
|
|
494
|
+
- Fewer safety blocks (`PROHIBITED_CONTENT`): every person in the examples is stated as an **adult** ("adult woman/man in her/his early twenties"), the milk-bath "욕조 꽃잎" example (implied nudity) was replaced by "장미 그네", and wording such as sheer / nude / boudoir / backside was toned down; "idol" removed from the beauty instruction, "alluring"/"intimate" removed from the variations.
|
|
495
|
+
- **Every new generation varies**: the same example no longer comes back in the same pose. Each fresh generate request (not follow-up edits) gets a random pose (24), main outfit/swimwear color (20), camera angle (8) and expression (7), avoiding the last half of each list used before (iOS UserDefaults `RNAttachmentKit.ai.recent.*`, Android prefs `recent.*`). Anything stated in the request or set in Options wins; pose is skipped for face close-up / selfie / face-hidden, angle for selfie / face-hidden. Source `prompts.json` `variations` → `AIPromptExamples.variation(...)`.
|
|
496
|
+
- 6 new strings (`ai_prompt_placeholder`, `ai_failed`, `ai_generate_placeholder`, `ai_generate_hint`, `ai_save`, `ai_generate`) in all 59 languages.
|
|
497
|
+
- Experiments not shipped: on-device InstructPix2Pix (model download + translation) → `archive/ai-local-model/`; OS one-tap tools (auto enhance / blur background / remove background) were replaced by natural-language editing.
|
|
48
498
|
|
|
49
499
|
## 1.0.5 — 2026-10-10
|
|
50
500
|
|