react-native-attachment-kit 1.0.5 → 1.0.8

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +100 -3
  3. package/android/build.gradle +14 -0
  4. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  5. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +280 -13
  6. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +282 -13
  7. package/ios/RNAttachmentKit.m +18 -0
  8. package/ios/RNAttachmentKit.swift +447 -9
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +21 -5
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Info.plist +0 -0
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +33440 -7566
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +193 -5
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +193 -5
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +15 -15
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +42 -10
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +33440 -7566
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +193 -5
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +193 -5
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +33440 -7566
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +193 -5
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  30. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +193 -5
  31. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  32. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  33. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +25 -25
  34. package/package.json +2 -2
  35. package/react-native-attachment-kit.podspec +1 -1
  36. package/src/NativeAttachmentKit.ts +5 -0
  37. package/src/index.ts +118 -7
  38. package/src/types.ts +173 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.8 — 2026-10-16
4
+
5
+ - **AI screens are the same in every app (iOS + Android)**. Generate: woman / man, country, age (up to the 80s), style, situation, aspect ratio + Create, sample chips and the chat input; chat text is sent together with the chosen settings, and tapping a sample shows a summary of the settings before creating. The glamour situations / samples and the detail options were removed from the generate screen. Edit: the full editing screen everywhere. Generated and edited images are stored for the gallery's "AI 이미지" tab in every app.
6
+ - iOS: the "AI 이미지" tab expands / collapses the sheet like the gallery grid.
7
+ - iOS: an edited (trimmed) video's first poster (`onProcessingStart` `thumbnailUri`) is taken from the trimmed start, with crop and drawing applied.
8
+ - iOS: closing the AI screen right after using a menu no longer leaves a stray button over the gallery.
9
+ - Android image viewer: video controls no longer flash on photo pages while the viewer opens.
10
+
11
+ ## 1.0.6 — 2026-10-14
12
+
13
+ - **Video in the picker, editor and viewer (iOS + Android)**: `openGallery({ mediaType: "all" | "video", video })` picks videos; the editor gets video pages (trim, mute, quality, crop, drawing/stickers/text, pre-encoding); `openViewer({ images, videos })` plays videos (progress pill, mute/options, remote video cache, pinch zoom). Sending progress is reported as events. `video.posterFormat` (`"webp"` default / `"jpg"`). Video limits are checked before sending.
14
+ - **In-app full-screen camera (iOS + Android)**: photo + hold-to-record video (with lock), ratio menu (꽉 참 / 9:16 / 3:4 / 1:1), resolution HD / FHD (default) / 4K, timer, grid, level, tap focus + exposure, stabilization, 30/60 fps, front screen flash, selfie mirror. The screen stays portrait; captures follow how the phone is held. Default lens 1×, smooth pinch zoom across lenses. The podspec now also links `CoreMotion`.
15
+ - **AI (Gemini) generate / edit**: `setAIConfig(...)`; generation screen, natural-language editing in the editor, recent works. Full features only in licensed (ZemTalk) apps; other apps get chat-only generate/edit.
16
+ - **Gallery attach menu "AI 이미지" tab**: shown when AI-made or AI-edited images exist; opens them in the image viewer (save, share, delete).
17
+ - **Image viewer video controls** readable in light mode (follow the Liquid Glass light/dark state).
18
+ - **Android performance**: the gallery opens without waiting for the camera; the camera cell is no longer rebound on scroll, tab change or editor pull-down; the editor zoom lands exactly on video pages.
19
+ - **iOS**: no empty-cell flash when a gallery photo opens in the editor; the AI images viewer no longer shows a red cell slot while open.
20
+
3
21
  ## 1.0.5 — 2026-10-10
4
22
 
5
23
  - iOS map viewer: Directions is a menu anchored above the button (the action sheet floated at the top on iOS 26); TMAP added (Korean only, with Kakao Map / Naver Map); cancelling the system "Open in …?" prompt no longer falls through to the web map (which opened the app anyway). Uses `canOpenURL` when the host declares `comgooglemaps`, `kakaomap`, `nmap`, `tmap` in `LSApplicationQueriesSchemes`.
package/README.md CHANGED
@@ -149,13 +149,16 @@ Opens the gallery bottom sheet. Resolves with the picked images (`[]` when the u
149
149
  | `outputFormat` | `'webp' \| 'jpg' \| 'png'` | `'webp'` | Output file format. (Not in the TS type yet — pass it with a cast.) |
150
150
  | `themeColor` / `languageCode` / `appearance` | | defaults | Per-call overrides. |
151
151
  | `doneButtonText` / `allItemsText` / `selectedItemsText` / `recentsAlbumText` | `string` | translated | Label overrides. |
152
- | `asyncProcessing` | `boolean` | `false` | Resolve right away and deliver each processed image through `onImageProcessed`. |
152
+ | `asyncProcessing` | `boolean` | `false` | Event-driven sending: each item is delivered through `onImageProcessed` as soon as it is ready (the Promise still resolves with everything at the end), and videos show no progress HUD — draw progress in your own UI. See [Sending progress](#sending-progress-events). |
153
153
  | `onSelectionChange` | `(e: SelectionChangeEvent) => void` | — | `{ selectedCount, maxSelection }` on every change. |
154
- | `onImageProcessed` | `(e) => void` | — | Per image when `asyncProcessing` is on. |
154
+ | `onProcessingStart` | `(e: ProcessingStartEvent) => void` | — | Once, right after Send, before any processing. |
155
+ | `onImageProcessed` | `(e) => void` | — | Per item `{ index, total, image }` as soon as it is ready. |
156
+ | `mediaType` | `'photo' \| 'all' \| 'video'` | `'photo'` | `'all'` mixes photos and videos. |
157
+ | `video` | `VideoOptions` | — | `maxDurationSec`, `maxFileSizeMB`, `qualities`, `defaultQuality`, `codec`, `onProgress`, `showProgressHUD`. |
155
158
  | `openDuration` / `closeDuration` | `number` (ms) | native | Animation durations. |
156
159
  | `showAttachMenu` … | | | Attach menu options — see [Attach menu](#attach-menu-location--file). |
157
160
 
158
- `mediaType` and `quality` are accepted for compatibility but currently ignored (photos only; output quality is fixed).
161
+ `quality` is accepted for compatibility but currently ignored (output quality is fixed).
159
162
 
160
163
  #### `ImageResult`
161
164
 
@@ -176,6 +179,26 @@ Closes the picker from code. `true` if it was open.
176
179
  #### `addSelectionChangeListener(listener): () => void`
177
180
  Selection events outside `openGallery`; returns an unsubscribe function.
178
181
 
182
+ #### Sending progress (events)
183
+
184
+ A chat can show every sent item at once and fill it in as it finishes (photos are resized, videos are encoded), the same way for photos and videos:
185
+
186
+ 1. **`onProcessingStart`** `{ total, items }`: once, right after Send. Each item has `index`, `mediaType` (`'image' | 'video'`), `originalUri`, and the expected output `width` / `height` (for the aspect ratio). Videos also have `duration` (seconds, after trim) and `thumbnailUri` / `thumbnailWidth` / `thumbnailHeight` (a poster jpg with crop and drawing applied). Create the placeholders here.
187
+ 2. **`onVideoProgress`** `{ index, total, progress }`: video encoding 0…1 (at most every 1% / 0.1 s). Draw a progress ring on that placeholder.
188
+ 3. **`onImageProcessed`** `{ index, total, image }`: the item is ready. Replace the placeholder with `image.uri`. A video the app cancelled comes as `{ error: 'CANCELLED', cancelled: true }`; a failed one has `error`.
189
+
190
+ ```ts
191
+ const sub = emitter.addListener('onProcessingStart', ({ items }) => addPlaceholders(items))
192
+ const prog = emitter.addListener('onVideoProgress', ({ index, progress }) => setProgress(index, progress))
193
+ const done = emitter.addListener('onImageProcessed', ({ index, image }) => fillPlaceholder(index, image))
194
+ await AttachmentKit.openGallery({ mediaType: 'all', asyncProcessing: true, enableEditor: true })
195
+ // or pass onProcessingStart / onImageProcessed / video.onProgress as options
196
+ ```
197
+
198
+ - **HUD:** `video.showProgressHUD` sets the full-screen progress HUD. The default is `true`, but `false` when `asyncProcessing: true`.
199
+ - **Cancel:** without the HUD, cancel from your UI with `AttachmentKit.cancelVideoProcessing(index?, processingId?)`. No `index` cancels all remaining videos; no `processingId` targets the latest send. The rest keep going, and the cancelled item is reported as above. It returns `false` when nothing is in progress.
200
+ - **Sending again while a video encodes:** the gallery can be opened again as soon as `onProcessingStart` arrives; you don't have to wait for the Promise, which still resolves with everything at the end. Every event carries `processingId`, so an app that listens with global listeners should use it to tell sends apart (`index` restarts at 0 for each send). Callbacks passed in the options are already filtered to their own send.
201
+
179
202
  ---
180
203
 
181
204
  ### Attach menu (Location / File)
@@ -438,6 +461,80 @@ A native grid built for thousands of photos. It opens like the picker (same bott
438
461
 
439
462
  ---
440
463
 
464
+ ### AI (generate & edit)
465
+
466
+ The gallery's ✦ button (AI image generation) and the editor's 6th tool **AI** (AI photo editing) use Google Gemini. They are **off until you call `setAIConfig({ enabled: true, ... })`**. The screens are the same in every app. Generate has woman / man, country, age, style and situation pickers, an aspect-ratio bar, sample chips and the chat input; chat text is sent together with the chosen settings. Edit is the full editing screen: edit choices, options, recommendation chips and the chat input. Generated and edited images are kept on the device and listed in the gallery's "AI 이미지" tab.
467
+
468
+ ```ts
469
+ await AttachmentKit.setAIConfig({
470
+ enabled: true,
471
+ endpoint: "https://api.example.com/ai/generate", // your server: forwards the Gemini generateContent body with your key
472
+ headers: { Authorization: `Bearer ${token}` }, // header X-AI-Model carries the model name
473
+ points: 1200, // current points
474
+ freeCount: 27, // subscribers: free uses left (e.g. 30 per month — your server decides); used before points
475
+ costPerUse: 100, // points per use (0 = free / unlimited)
476
+ highQualityCost: 150, // points per use with Quality "High (1K)" (default: costPerUse)
477
+ })
478
+
479
+ const offUsage = AttachmentKit.addAIUsageListener(e => {
480
+ // { kind: "generate" | "edit", usedFreeCount, pointsDeducted, highQuality, points, freeCount }
481
+ api.reportAIUsage(e)
482
+ })
483
+ const offCharge = AttachmentKit.addAIChargeRequiredListener(e => {
484
+ // { kind, required, points, freeCount } — the AI screen has closed
485
+ AttachmentKit.closeGallery(); navigation.navigate("PointShop")
486
+ })
487
+ ```
488
+
489
+ - Only the values you pass change; call it again whenever the balance changes (after a top-up, after a server sync). An open AI screen updates at once.
490
+ - Order of use: free uses → points → when points are short the user sees "Not enough points · Top up" (Top up → `onAIChargeRequired`, AI screen closes).
491
+ - Only successful requests are charged (failures, cancel and safety blocks are free). The module lowers its own copy and sends `onAIUsage`; your server stays the source of truth.
492
+ - The AI screen title shows "N free left" or "points · cost per use".
493
+ - `apiKey` exists for testing only — never ship a Gemini key in the app.
494
+
495
+ #### Your backend (the `endpoint`)
496
+
497
+ The module never needs a Gemini key in the app. Point `endpoint` at **your own server**; it only has to pass the request through to Gemini with your key.
498
+
499
+ **Request the module sends**
500
+
501
+ ```
502
+ POST {endpoint}
503
+ Content-Type: application/json
504
+ X-AI-Model: gemini-3.1-flash-image ← model to call (image: gemini-3.1-flash-image / gemini-3.1-flash-lite-image, photo analysis: gemini-3.5-flash-lite)
505
+ …plus every header you passed in setAIConfig({ headers }) ← e.g. Authorization
506
+
507
+ { …Gemini generateContent request body… }
508
+ ```
509
+
510
+ **What your server does**
511
+
512
+ 1. Check the user (your `headers`) and that `X-AI-Model` is a model you allow.
513
+ 2. Forward the body unchanged to `https://generativelanguage.googleapis.com/v1beta/models/{X-AI-Model}:generateContent` with header `x-goog-api-key: <your key>`.
514
+ 3. Return Gemini's status code and JSON body **as is**.
515
+
516
+ ```js
517
+ // Minimal Express example
518
+ app.post('/ai/generate', auth, express.json({ limit: '20mb' }), async (req, res) => {
519
+ const model = req.get('X-AI-Model');
520
+ if (!ALLOWED_MODELS.includes(model)) return res.status(400).json({ code: 'MODEL_NOT_ALLOWED' });
521
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': process.env.GEMINI_API_KEY },
524
+ body: JSON.stringify(req.body),
525
+ });
526
+ res.status(r.status).type('application/json').send(await r.text());
527
+ });
528
+ ```
529
+
530
+ **Billing (optional, on your server)** — the module's `points` / `freeCount` are only a display copy. If you charge, do it on the server: reserve before calling Gemini, confirm only when the response contains an image, refund otherwise (failures and safety blocks should be free). Then send the new balance back with `setAIConfig` (e.g. in `addAIUsageListener`).
531
+
532
+ **Errors** — any status other than 200 is a failed attempt (nothing is deducted in the module). To show your own reason, answer in Gemini's error shape: `{ "error": { "message": "…" } }`. The "Not enough points · Top up" prompt comes from the module's own copy of `points` / `freeCount`, so keep it in sync with `setAIConfig`.
533
+
534
+ **Security** — keep the key only on the server, allow-list models, limit the body size, and rate-limit per user (photo analysis requests carry no image output and are easy to abuse if left unlimited).
535
+
536
+ ---
537
+
441
538
  ### Misc
442
539
 
443
540
  | API | Description |
@@ -112,13 +112,27 @@ if (project != rootProject) {
112
112
  // 첨부 메뉴 > 위치 — AAR 내부 의존성은 앱으로 전달되지 않으므로 여기에도 선언 (Glide 와 같은 이유)
113
113
  implementation "com.google.android.gms:play-services-maps:19.0.0"
114
114
  implementation "com.google.android.gms:play-services-location:21.3.0"
115
+ // AI 편집: 무료 기기 사진 분석 (얼굴·이미지 라벨). Play 서비스판이라 모델은 기기가 받아 둠 (APK 용량 거의 안 늘어남)
116
+ implementation "com.google.android.gms:play-services-mlkit-face-detection:17.1.0"
117
+ implementation "com.google.android.gms:play-services-mlkit-image-labeling:16.0.8"
115
118
 
119
+ // 동영상 (media3 Transformer) — AAR 내부 의존성은 앱으로 전달되지 않으므로 여기에도 선언
120
+ def media3_version = "1.4.1"
121
+ implementation "androidx.media3:media3-common:${media3_version}"
122
+ implementation "androidx.media3:media3-exoplayer:${media3_version}"
123
+ // 이미지 뷰어 동영상: HLS(.m3u8) 스트리밍 재생 (iOS AVPlayer 와 맞춤)
124
+ implementation "androidx.media3:media3-exoplayer-hls:${media3_version}"
125
+ implementation "androidx.media3:media3-ui:${media3_version}"
126
+ implementation "androidx.media3:media3-transformer:${media3_version}"
127
+ implementation "androidx.media3:media3-effect:${media3_version}"
128
+
116
129
  // CameraX dependencies
117
130
  def camerax_version = "1.3.1"
118
131
  implementation "androidx.camera:camera-core:${camerax_version}"
119
132
  implementation "androidx.camera:camera-camera2:${camerax_version}"
120
133
  implementation "androidx.camera:camera-lifecycle:${camerax_version}"
121
134
  implementation "androidx.camera:camera-view:${camerax_version}"
135
+ implementation "androidx.camera:camera-video:${camerax_version}" // 전체 화면 카메라 동영상 녹화
122
136
  implementation "androidx.camera:camera-extensions:${camerax_version}"
123
137
  }
124
138
  }
@@ -40,6 +40,33 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
40
40
  private var maxWidth: Int = 1024
41
41
  private var maxHeight: Int = 1024
42
42
  private var outputFormat: String = "webp" // Default to webp
43
+ // ─── 동영상 (openGallery mediaType / video 옵션) ───
44
+ private var mediaType: String = "photo"
45
+ private var videoOptions = com.rnattachmentkit.VideoExportOptions()
46
+ /** onSelectionChange 에 실어 보낼 maxSelection (예전엔 10 고정) */
47
+ private var lastMaxSelection: Int = 10
48
+ private var lastThemeColorInt: Int? = null
49
+ private var lastLanguageCode: String = "en"
50
+ @Volatile private var currentVideoExporter: com.rnattachmentkit.VideoExporter? = null
51
+ @Volatile private var videoCancelled = false
52
+ /** 진행 중인 전송 처리 (processingId → 작업). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음 */
53
+ private val activeProcessingJobs = java.util.concurrent.ConcurrentHashMap<String, com.rnattachmentkit.ProcessingJob>()
54
+ /** 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때) */
55
+ @Volatile private var lastProcessingId: String? = null
56
+
57
+ /** 보낸 순간의 설정 — 처리 중에 갤러리를 다시 열면 모듈 필드가 새 호출 값으로 바뀌므로, 처리는 이 사본만 씀 */
58
+ private class SendSnapshot(
59
+ val maxWidth: Int, val maxHeight: Int, val outputFormat: String,
60
+ val videoOptions: com.rnattachmentkit.VideoExportOptions,
61
+ val states: Map<String, com.rnattachmentkit.VideoEditState>,
62
+ val showHUD: Boolean, val themeColor: Int?, val language: String
63
+ )
64
+ private fun sendSnapshot() = SendSnapshot(maxWidth, maxHeight, outputFormat, videoOptions, HashMap(lastVideoEditStates),
65
+ showVideoHUD, lastThemeColorInt, lastLanguageCode)
66
+ /** 동영상 변환 진행 HUD — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만 (이벤트로 말풍선에 그리는 앱은 HUD 없이) */
67
+ private var showVideoHUD = true
68
+ /** 시트가 닫힐 때 넘겨받은 동영상 편집 상태 (원본 uri 문자열 → 품질·음소거) */
69
+ private var lastVideoEditStates: Map<String, com.rnattachmentkit.VideoEditState> = emptyMap()
43
70
 
44
71
  // newarch에는 이 맵과 getDefaultAnimationConfig() 구현이 통째로 빠져 있었습니다(oldarch에는 있었음).
45
72
  // NativeAttachmentKitSpec(codegen 생성 추상 클래스)이 이 메서드를 abstract로 요구하기 때문에,
@@ -317,6 +344,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
317
344
  false
318
345
  }
319
346
 
347
+ // 동영상: mediaType "photo"(기본) | "video" | "all", video: VideoOptions
348
+ mediaType = (if (options.hasKey("mediaType") && !options.isNull("mediaType")) options.getString("mediaType") else null)
349
+ ?.lowercase()?.takeIf { it == "video" || it == "all" } ?: "photo"
350
+ videoOptions = com.rnattachmentkit.VideoExportOptions.fromMap(
351
+ if (options.hasKey("video") && !options.isNull("video")) options.getMap("video")?.toHashMap() else null
352
+ )
353
+ run {
354
+ val async = options.hasKey("asyncProcessing") && !options.isNull("asyncProcessing") && options.getBoolean("asyncProcessing")
355
+ val v = if (options.hasKey("video") && !options.isNull("video")) options.getMap("video") else null
356
+ showVideoHUD = if (v != null && v.hasKey("showProgressHUD") && !v.isNull("showProgressHUD")) v.getBoolean("showProgressHUD") else !async
357
+ }
358
+ lastMaxSelection = maxSelection
359
+ TurboImagePickerConfig.videoOptions = videoOptions
360
+ lastLanguageCode = languageCode
361
+ lastThemeColorInt = try { themeColor?.let { android.graphics.Color.parseColor(it) } } catch (_: Exception) { null }
362
+
320
363
  val fragmentActivity = activity as? FragmentActivity
321
364
  if (fragmentActivity == null) {
322
365
  promise.reject(E_NO_ACTIVITY, "Activity is not a FragmentActivity")
@@ -345,6 +388,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
345
388
  } else {
346
389
  Manifest.permission.READ_EXTERNAL_STORAGE
347
390
  }
391
+ // Android 13+ 에서 동영상도 보이면 READ_MEDIA_VIDEO 를 같이 요청
392
+ val mediaPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && mediaType != "photo") {
393
+ arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO)
394
+ } else {
395
+ arrayOf(permission)
396
+ }
348
397
 
349
398
  val showSheet = {
350
399
  fragmentActivity.runOnUiThread {
@@ -361,7 +410,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
361
410
  options.getDouble("attachMenuMinBottomMargin").toFloat() else com.rnattachmentkit.AttachMenuBarView.BOTTOM_MARGIN_DP.toFloat(),
362
411
  // 위치 보내기 때 지도 이미지 캡처 여부. 기본 true (기존 그대로). false 면 이미지 없이 위치 정보만
363
412
  captureLocationImage = !(options.hasKey("captureLocationImage") && !options.isNull("captureLocationImage")) ||
364
- options.getBoolean("captureLocationImage")
413
+ options.getBoolean("captureLocationImage"),
414
+ // 동영상 (mediaType "video"/"all" 일 때 그리드에 동영상, 길이 제한)
415
+ mediaType = mediaType,
416
+ maxVideoDurationSec = videoOptions.maxDurationSec
365
417
  )
366
418
  // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
367
419
  bottomSheet.setOnLocationSelectedListener { loc ->
@@ -407,6 +459,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
407
459
  settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
408
460
  }
409
461
  bottomSheet.setOnImagesSelectedListener { uris ->
462
+ lastVideoEditStates = HashMap(bottomSheet.videoEditStates)
410
463
  val result = WritableNativeArray()
411
464
  for (uri in uris) {
412
465
  result.pushString(uri.toString())
@@ -432,7 +485,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
432
485
  }
433
486
  }
434
487
 
435
- if (ContextCompat.checkSelfPermission(fragmentActivity, permission) == PackageManager.PERMISSION_GRANTED) {
488
+ if (mediaPermissions.all { ContextCompat.checkSelfPermission(fragmentActivity, it) == PackageManager.PERMISSION_GRANTED }) {
436
489
  showSheet()
437
490
  } else {
438
491
  val permissionAwareActivity = fragmentActivity as? PermissionAwareActivity
@@ -447,21 +500,19 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
447
500
  showSheet()
448
501
  return
449
502
  }
450
- arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
503
+ mediaPermissions + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
451
504
  } else {
452
- arrayOf(permission)
505
+ mediaPermissions
453
506
  }
454
507
 
455
508
  permissionAwareActivity.requestPermissions(permissionsToRequest, 100, object : PermissionListener {
456
509
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Boolean {
457
510
  if (requestCode == 100 && grantResults != null) {
458
511
  val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
459
- grantResults.isNotEmpty() && (
460
- grantResults[0] == PackageManager.PERMISSION_GRANTED ||
461
- (grantResults.size > 1 && grantResults[1] == PackageManager.PERMISSION_GRANTED)
462
- )
512
+ // 14+: 전체 허용이든 "일부 선택"이든 하나라도 허용이면 진행
513
+ grantResults.any { it == PackageManager.PERMISSION_GRANTED }
463
514
  } else {
464
- grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
515
+ grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
465
516
  }
466
517
 
467
518
  if (isGranted) {
@@ -600,6 +651,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
600
651
  putStringArrayList("placeholderImages", placeholders)
601
652
  }
602
653
  }
654
+ // 동영상 페이지 (뷰어 동영상 작업 지시서 V1): videos[i] = { uri, cacheFile? } 또는 null(사진).
655
+ // images[i] 는 그 동영상의 포스터. 이 오버레이 뷰어에서만 재생합니다.
656
+ if (options.hasKey("videos")) {
657
+ val videoArray = options.getArray("videos")
658
+ if (videoArray != null) {
659
+ val uris = ArrayList<String>()
660
+ val caches = ArrayList<String>()
661
+ for (i in 0 until videoArray.size()) {
662
+ val m = if (videoArray.getType(i) == com.facebook.react.bridge.ReadableType.Map) videoArray.getMap(i) else null
663
+ uris.add(if (m != null && m.hasKey("uri") && !m.isNull("uri")) m.getString("uri") ?: "" else "")
664
+ caches.add(if (m != null && m.hasKey("cacheFile") && !m.isNull("cacheFile")) m.getString("cacheFile") ?: "" else "")
665
+ }
666
+ putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_URIS, uris)
667
+ putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_CACHE_FILES, caches)
668
+ }
669
+ }
603
670
  // 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
604
671
  // 네이티브 쪽 로케일 리소스(values-ko/ja/zh, 그 외 en)가 자동 적용됩니다.
605
672
  putBoolean("showActionButtons", !options.hasKey("showActionButtons") || options.getBoolean("showActionButtons"))
@@ -761,6 +828,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
761
828
  }
762
829
  }
763
830
 
831
+ /** 동영상 변환 취소 (HUD 없이 앱이 말풍선에서). index -1 = 남은 동영상 전부. 진행 중인 처리가 없으면 false */
832
+ override fun cancelVideoProcessing(index: Double, processingId: String, promise: Promise) {
833
+ val job = (processingId.ifEmpty { lastProcessingId })?.let { activeProcessingJobs[it] }
834
+ if (job == null) { promise.resolve(false); return }
835
+ job.cancel(if (index >= 0) index.toInt() else null)
836
+ promise.resolve(true)
837
+ }
838
+
764
839
  override fun closeGallery(promise: Promise) {
765
840
  val activity = getCurrentActivity()
766
841
  if (activity == null) {
@@ -809,9 +884,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
809
884
  return
810
885
  }
811
886
 
887
+ val snap = sendSnapshot()
812
888
  moduleScope.launch {
813
889
  try {
814
- val results = processImages(uris)
890
+ val results = processImages(uris, snap)
815
891
  if (results.isNotEmpty()) {
816
892
  promise.resolve(results[0])
817
893
  } else {
@@ -855,7 +931,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
855
931
  // Process images asynchronously
856
932
  val eventMap = com.facebook.react.bridge.WritableNativeMap()
857
933
  eventMap.putInt("selectedCount", uris.size)
858
- eventMap.putInt("maxSelection", 10)
934
+ eventMap.putInt("maxSelection", lastMaxSelection)
859
935
  try {
860
936
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
861
937
  .emit("onSelectionChange", eventMap)
@@ -863,25 +939,183 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
863
939
  // Ignore
864
940
  }
865
941
 
942
+ val snap = sendSnapshot()
866
943
  moduleScope.launch {
867
944
  try {
868
- val results = processImages(uris)
945
+ val results = processImages(uris, snap)
869
946
  val array = com.facebook.react.bridge.Arguments.createArray()
870
947
  results.forEach { array.pushMap(it) }
871
948
  actualPromise.resolve(array)
949
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
950
+ actualPromise.reject("CANCELLED", "Video export cancelled")
872
951
  } catch (e: Exception) {
873
952
  actualPromise.reject(E_FAILED_TO_PICK, "Failed to process images: ${e.message}", e)
874
953
  }
875
954
  }
876
955
  }
877
956
 
878
- private suspend fun processImages(uris: List<Uri>): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
957
+ private suspend fun processImages(uris: List<Uri>, snap: SendSnapshot): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
958
+ // 보낸 순간의 설정 (모듈 필드와 같은 이름으로 가림 — 아래 코드는 그대로)
959
+ val maxWidth = snap.maxWidth
960
+ val maxHeight = snap.maxHeight
961
+ val outputFormat = snap.outputFormat
962
+ val videoOptions = snap.videoOptions
963
+ val lastVideoEditStates = snap.states
964
+ val showVideoHUD = snap.showHUD
965
+ val lastThemeColorInt = snap.themeColor
966
+ val lastLanguageCode = snap.language
967
+ val processingId = java.util.UUID.randomUUID().toString()
879
968
  val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
880
969
  val contentResolver = reactApplicationContext.contentResolver
881
970
  val totalCount = uris.size
882
971
  var currentIndex = 0
883
972
 
973
+ // 동영상이 섞여 있으면 진행 HUD 를 띄우고, 동영상은 VideoExporter 로 (사진 처리와 같은 루프)
974
+ val isVideoUri: (Uri) -> Boolean = { u ->
975
+ u.toString().contains("/video/") || (try { contentResolver.getType(u)?.startsWith("video/") == true } catch (_: Exception) { false })
976
+ }
977
+ val hasVideo = uris.any(isVideoUri)
978
+ videoCancelled = false
979
+ val job = com.rnattachmentkit.ProcessingJob()
980
+ activeProcessingJobs[processingId] = job
981
+ lastProcessingId = processingId
982
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
983
+ try {
984
+ val items = Arguments.createArray()
985
+ uris.forEachIndexed { i, u ->
986
+ val it0 = if (isVideoUri(u)) {
987
+ val st = lastVideoEditStates[u.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
988
+ com.rnattachmentkit.MediaProcessing.videoItem(reactApplicationContext, i, u, st, videoOptions)
989
+ } else {
990
+ com.rnattachmentkit.MediaProcessing.imageItem(reactApplicationContext, i, u, maxWidth, maxHeight)
991
+ }
992
+ items.pushMap(Arguments.createMap().apply {
993
+ putInt("index", it0.index)
994
+ putString("mediaType", if (it0.isVideo) "video" else "image")
995
+ putString("originalUri", it0.originalUri)
996
+ putInt("width", it0.width)
997
+ putInt("height", it0.height)
998
+ it0.durationSec?.let { d -> putDouble("duration", d) }
999
+ it0.thumbnailUri?.let { t ->
1000
+ putString("thumbnailUri", t)
1001
+ putInt("thumbnailWidth", it0.thumbnailWidth)
1002
+ putInt("thumbnailHeight", it0.thumbnailHeight)
1003
+ }
1004
+ })
1005
+ }
1006
+ emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1007
+ putString("processingId", processingId)
1008
+ putInt("total", totalCount)
1009
+ putArray("items", items)
1010
+ })
1011
+ } catch (e: Exception) {
1012
+ android.util.Log.w("RNAttachmentKit", "onProcessingStart failed: ${e.message}")
1013
+ }
1014
+ var hud: com.rnattachmentkit.VideoExportHUD? = null
1015
+ if (hasVideo && showVideoHUD) {
1016
+ withContext(Dispatchers.Main) {
1017
+ val act = getCurrentActivity() ?: return@withContext
1018
+ hud = com.rnattachmentkit.VideoExportHUD(act, lastThemeColorInt, lastLanguageCode).also { h ->
1019
+ h.onCancel = {
1020
+ videoCancelled = true
1021
+ currentVideoExporter?.cancel()
1022
+ }
1023
+ h.show()
1024
+ }
1025
+ }
1026
+ }
1027
+ val videoFiles = mutableListOf<File>()
1028
+
1029
+ try {
884
1030
  for (uri in uris) {
1031
+ if (videoCancelled) throw com.rnattachmentkit.VideoExporter.CancelledException()
1032
+ if (isVideoUri(uri)) {
1033
+ val idx = currentIndex
1034
+ val exporter = com.rnattachmentkit.VideoExporter(reactApplicationContext)
1035
+ currentVideoExporter = exporter
1036
+ val cancelledMap = {
1037
+ WritableNativeMap().apply {
1038
+ putString("originalUri", uri.toString())
1039
+ putString("mediaType", "video")
1040
+ putString("error", "CANCELLED")
1041
+ putBoolean("cancelled", true)
1042
+ }
1043
+ }
1044
+ val resultMap = try {
1045
+ // 앱이 이미 취소한 동영상은 건너뜀
1046
+ if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1047
+ job.begin(idx, exporter)
1048
+ val state = lastVideoEditStates[uri.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1049
+ val throttle = com.rnattachmentkit.ProgressThrottle()
1050
+ val onProgress: (Float) -> Unit = { p ->
1051
+ hud?.let { h -> android.os.Handler(android.os.Looper.getMainLooper()).post { h.update(p, idx, totalCount) } }
1052
+ if (throttle.shouldSend(p)) emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1053
+ putInt("index", idx); putInt("total", totalCount); putDouble("progress", p.toDouble())
1054
+ putString("processingId", processingId)
1055
+ })
1056
+ }
1057
+ // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1058
+ // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1059
+ val stop = { videoCancelled || job.isCancelled(idx) }
1060
+ val res = com.rnattachmentkit.VideoPrepareManager.takeReady(uri, state, onProgress, stop)
1061
+ ?: run {
1062
+ if (stop()) throw com.rnattachmentkit.VideoExporter.CancelledException()
1063
+ exporter.export(uri, state, videoOptions, reactApplicationContext.cacheDir, "turbo_video_${System.currentTimeMillis()}_$idx", onProgress)
1064
+ }
1065
+ withContext(Dispatchers.Main) { hud?.update(1f, idx, totalCount) }
1066
+ // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1067
+ if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1068
+ videoFiles += res.file
1069
+ res.thumbnailFile?.let { videoFiles += it }
1070
+ WritableNativeMap().apply {
1071
+ putString("uri", Uri.fromFile(res.file).toString())
1072
+ putString("originalUri", uri.toString())
1073
+ putString("mediaType", "video")
1074
+ putString("type", "video/mp4")
1075
+ putInt("width", res.width)
1076
+ putInt("height", res.height)
1077
+ putDouble("duration", res.durationMs / 1000.0)
1078
+ putDouble("fileSize", res.fileSize.toDouble())
1079
+ putString("fileName", res.file.name)
1080
+ putString("fileExtension", "mp4")
1081
+ putString("videoCodec", res.codec)
1082
+ putInt("videoBitrate", res.bitrate)
1083
+ putBoolean("edited", res.edited)
1084
+ if (res.thumbnailFile != null) {
1085
+ putString("thumbnailUri", Uri.fromFile(res.thumbnailFile).toString())
1086
+ putInt("thumbnailWidth", res.thumbnailWidth)
1087
+ putInt("thumbnailHeight", res.thumbnailHeight)
1088
+ }
1089
+ }
1090
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1091
+ // 앱이 이 동영상만 취소 → 나머지는 계속. HUD 취소면 전체 취소
1092
+ if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else throw e
1093
+ } catch (e: Exception) {
1094
+ // 취소하면서 변환기가 다른 예외로 끝날 수도 있음 → 취소로 처리
1095
+ if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else {
1096
+ android.util.Log.w("RNAttachmentKit", "video export failed: ${e.message}", e)
1097
+ WritableNativeMap().apply {
1098
+ putString("originalUri", uri.toString())
1099
+ putString("mediaType", "video")
1100
+ putString("error", e.message ?: "export failed")
1101
+ }
1102
+ }
1103
+ } finally {
1104
+ currentVideoExporter = null
1105
+ job.end()
1106
+ }
1107
+ results.add(resultMap)
1108
+ try {
1109
+ val copy = WritableNativeMap().apply { merge(resultMap) }
1110
+ reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1111
+ .emit("onImageProcessed", WritableNativeMap().apply {
1112
+ putInt("total", totalCount); putInt("index", idx); putMap("image", copy)
1113
+ putString("processingId", processingId)
1114
+ })
1115
+ } catch (_: Exception) {}
1116
+ currentIndex++
1117
+ continue
1118
+ }
885
1119
  try {
886
1120
  // 1. Get EXIF Orientation
887
1121
  var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
@@ -1012,6 +1246,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1012
1246
  val eventMap = WritableNativeMap().apply {
1013
1247
  putInt("total", totalCount)
1014
1248
  putInt("index", currentIndex)
1249
+ putString("processingId", processingId)
1015
1250
  putMap("image", createImageInfo())
1016
1251
  }
1017
1252
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@@ -1027,6 +1262,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1027
1262
  continue
1028
1263
  }
1029
1264
  }
1265
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1266
+ // 취소: 지금까지 만든 동영상 파일은 지움
1267
+ videoFiles.forEach { try { it.delete() } catch (_: Exception) {} }
1268
+ throw e
1269
+ } finally {
1270
+ activeProcessingJobs.remove(processingId)
1271
+ if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
1272
+ }
1030
1273
 
1031
1274
  results
1032
1275
  }
@@ -1305,6 +1548,13 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1305
1548
  )
1306
1549
  if (rect != null) f.sourceRectProvider = { mapViewerSourceRect }
1307
1550
  var settled = false
1551
+ f.onWillClose = { ms ->
1552
+ try {
1553
+ reactApplicationContext
1554
+ .getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1555
+ .emit("onMapViewerWillClose", Arguments.createMap().apply { putInt("durationMs", ms) })
1556
+ } catch (_: Exception) {}
1557
+ }
1308
1558
  f.onClosed = {
1309
1559
  if (!settled) { settled = true; promise.resolve(null) }
1310
1560
  mapViewerSourceRect = null
@@ -1432,6 +1682,23 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1432
1682
  }
1433
1683
  }
1434
1684
 
1685
+ // ── AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림 ──
1686
+
1687
+ override fun setAIConfig(config: ReadableMap, promise: Promise) {
1688
+ AIBilling.apply(config.toHashMap())
1689
+ val ctx = reactApplicationContext
1690
+ fun emit(name: String, body: Map<String, Any>) {
1691
+ // 앱이 내려가는 중이면 조용히 무시
1692
+ runCatching {
1693
+ ctx.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1694
+ .emit(name, com.facebook.react.bridge.Arguments.makeNativeMap(body))
1695
+ }
1696
+ }
1697
+ AIBilling.onUsage = { emit("onAIUsage", it) }
1698
+ AIBilling.onChargeRequired = { emit("onAIChargeRequired", it) }
1699
+ promise.resolve(null)
1700
+ }
1701
+
1435
1702
  // ── 역지오코딩 (기기 Geocoder, 디스크 캐시) ──
1436
1703
 
1437
1704
  override fun reverseGeocode(options: ReadableMap, promise: Promise) {