react-native-attachment-kit 1.0.6 → 1.0.9

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 (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +41 -3
  3. package/android/CMakeLists.txt +3 -0
  4. package/android/build.gradle +11 -2
  5. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  6. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +48 -2
  7. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +48 -2
  8. package/ios/RNAttachmentKit.swift +160 -5
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +1283 -810
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +12 -1
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +12 -1
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +1283 -810
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +12 -1
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +12 -1
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +1283 -810
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +12 -1
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +12 -1
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +22 -22
  30. package/package.json +2 -2
  31. package/src/index.ts +1 -1
  32. package/src/types.ts +34 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 1.0.6 — 2026-10-14
4
12
 
5
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.
package/README.md CHANGED
@@ -98,7 +98,7 @@ Enable on the key: **Maps SDK for Android**, and **Places API (New)** for place
98
98
  import AttachmentKit from 'react-native-attachment-kit'
99
99
 
100
100
  // Once at app start
101
- await AttachmentKit.init('YOUR_LICENSE_KEY', { // '' = evaluation (editor watermark)
101
+ await AttachmentKit.init('YOUR_LICENSE_KEY', { // '' = evaluation: photos only (editor watermark; no location / video / AI)
102
102
  languageCode: 'ko', // any app language code; see Localization
103
103
  themeColor: '#10b981',
104
104
  appearance: 'system', // 'system' | 'light' | 'dark'
@@ -147,6 +147,7 @@ Opens the gallery bottom sheet. Resolves with the picked images (`[]` when the u
147
147
  | `profileMode` | `boolean` | `false` | 1:1 (circle) crop for profile photos. |
148
148
  | `maxWidth` / `maxHeight` | `number` | `1024` | Output images are scaled down to fit. |
149
149
  | `outputFormat` | `'webp' \| 'jpg' \| 'png'` | `'webp'` | Output file format. (Not in the TS type yet — pass it with a cast.) |
150
+ | `thumbnail` | `ThumbnailOptions` | — | Also make a small photo thumbnail for each photo — see [Photo thumbnails](#photo-thumbnails). Omitted: no photo thumbnail (same as before). |
150
151
  | `themeColor` / `languageCode` / `appearance` | | defaults | Per-call overrides. |
151
152
  | `doneButtonText` / `allItemsText` / `selectedItemsText` / `recentsAlbumText` | `string` | translated | Label overrides. |
152
153
  | `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). |
@@ -172,6 +173,32 @@ Opens the gallery bottom sheet. Resolves with the picked images (`[]` when the u
172
173
  | `type` | `string` | MIME type. |
173
174
  | `caption?` | `string` | Caption typed in the editor. |
174
175
  | `fileName?` / `fileExtension?` / `fileSize?` | | Output file info (bytes). |
176
+ | `thumbnailUri?` / `thumbnailWidth?` / `thumbnailHeight?` | | Photo: the thumbnail when the `thumbnail` option is set. Video: the poster (always, see `video.posterFormat`). |
177
+ | `thumbnailFileSize?` | `number` | Photo thumbnail file size (bytes). |
178
+
179
+ #### Photo thumbnails
180
+
181
+ Pass `thumbnail` to `openGallery` (or `openEditor`) to get a small thumbnail next to each photo — e.g. to upload both the full image and a list-size image in one request.
182
+
183
+ ```ts
184
+ // Profile avatar: exactly 200×200, center-cropped
185
+ await AttachmentKit.openGallery({ maxWidth: 512, maxHeight: 512, thumbnail: { maxWidth: 200, mode: 'cover' } })
186
+ // Moments list: keep the aspect ratio, longest side ≤ 360
187
+ await AttachmentKit.openGallery({ maxSelection: 10, thumbnail: { maxWidth: 360 } })
188
+ ```
189
+
190
+ | Option | Type | Default | Description |
191
+ |---|---|---|---|
192
+ | `maxWidth` | `number` (px) | **required** | Box width. |
193
+ | `maxHeight` | `number` (px) | `maxWidth` | Box height. |
194
+ | `mode` | `'fit' \| 'cover'` | `'fit'` | `'fit'`: keep the aspect ratio inside the box. `'cover'`: fill the box and crop the center → exactly `maxWidth × maxHeight`. |
195
+ | `quality` | `number` 0–1 | `0.8` | Encoding quality. |
196
+ | `format` | `'webp' \| 'jpg'` | `'webp'` | Falls back to jpg if webp encoding fails (check the extension of `thumbnailUri`). |
197
+
198
+ - Made from the same image as `uri` (orientation, crop, drawing and filter applied) — the original is not decoded again. No EXIF / GPS is written.
199
+ - Never enlarged: a 100×80 photo gives 100×80 with `{ maxWidth: 360 }` and 80×80 with `{ maxWidth: 200, mode: 'cover' }`. iOS and Android give the same size.
200
+ - Also in `onImageProcessed` (`image.thumbnailUri` …). Video posters keep their own rules (longest side 512, `video.posterFormat`) and ignore this option.
201
+ - Files: `turbo_picker_thumb_<time>_<index>.webp` next to the output photo; deleted after 24 h like the other temp files.
175
202
 
176
203
  #### `closeGallery(): Promise<boolean>`
177
204
  Closes the picker from code. `true` if it was open.
@@ -262,6 +289,7 @@ Both callbacks fire before `openGallery` resolves; their listeners are removed w
262
289
  | `uri` | `string` | Image to edit (`originalUri` from a picker result). |
263
290
  | `editedFileUri?` | `string` | A previous edit to continue from. |
264
291
  | `maxWidth` / `maxHeight` | `number` | Output bounds. |
292
+ | `thumbnail?` | `ThumbnailOptions` | Also make a thumbnail of the edited result (crop and drawing applied). See [Photo thumbnails](#photo-thumbnails). |
265
293
  | `themeColor` / `appearance` | | Per-call overrides. |
266
294
  | `openDuration` / `closeDuration` | `number` (ms) | Animation durations. |
267
295
 
@@ -463,7 +491,7 @@ A native grid built for thousands of photos. It opens like the picker (same bott
463
491
 
464
492
  ### AI (generate & edit)
465
493
 
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, ... })`**. In apps other than ZemTalk the AI screens are chat-only: generate shows an aspect-ratio bar and the chat input, edit shows the photo and the chat input, and the text is sent to the model as typed. The style/option menus, sample prompts, recommendation chips and recent works are ZemTalk-only.
494
+ 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
495
 
468
496
  ```ts
469
497
  await AttachmentKit.setAIConfig({
@@ -562,6 +590,7 @@ app.post('/ai/generate', auth, express.json({ limit: '20mb' }), async (req, res)
562
590
  |---|---|---|---|
563
591
  | Files from the File tab | `tmp/AttachmentKitFiles/<uuid>/<name>` | `cache/AttachmentKitFiles/<uuid>/<name>` | Deleted after 24 h (next gallery open); the OS may purge earlier — copy/upload promptly. |
564
592
  | Location snapshots | `tmp/AttachmentKitLocation/` | `cache/AttachmentKitLocation/` | Deleted after 24 h. |
593
+ | Photo thumbnails (`thumbnail` option) | `Library/Caches/RNAttachmentKit/turbo_picker_thumb_*` | `cache/turbo_picker_thumb_*` | Deleted after 24 h (next gallery open). |
565
594
  | Location thumbnails | `Library/Caches/RNAttachmentKit/LocationThumbs/` | `cache/RNAttachmentKitLocationThumbs/` | 300 max → 250, least recently used. `clearLocationThumbnailCache()`. |
566
595
  | Reverse-geocode results | `Library/Caches/RNAttachmentKit/Geocode/` | `cache/RNAttachmentKitGeocode/` | 1000 max → 800, least recently used. `clearReverseGeocodeCache()`. |
567
596
 
@@ -569,7 +598,16 @@ app.post('/ai/generate', auth, express.json({ limit: '20mb' }), async (req, res)
569
598
 
570
599
  ## 📜 License & Contact
571
600
 
572
- `react-native-attachment-kit` is released under the **MIT License**. Removing the editor watermark requires a license key passed to `init()`.
601
+ `react-native-attachment-kit` is released under the **MIT License**. A license key passed to `init()` (one key per app ID — bundle ID / package name) removes the editor watermark and unlocks location, video and AI.
602
+
603
+ Without a valid key (evaluation), only photos work: picking, editing (with watermark) and viewing.
604
+
605
+ | Feature | Without a license key |
606
+ |---|---|
607
+ | Attach menu Location tab, ✦ AI generate, editor AI tool, "AI 이미지" tab | Visible; tapping shows a "License required" notice |
608
+ | `openGallery({ mediaType: 'video' \| 'all' })` | Photos only; holding the shutter to record shows the notice |
609
+ | `openMapViewer`, `renderLocationThumbnail`, `reverseGeocode`, `openViewer` with `videos`, `openMediaGallery` / `appendGalleryItems` with video items | Rejected with `E_LICENSE_REQUIRED` |
610
+ | `setAIConfig` | Accepted (the AI buttons appear and show the notice) |
573
611
 
574
612
  🛒 **License**: [https://license.rnturboimagepicker.usomnia.co.kr](https://license.rnturboimagepicker.usomnia.co.kr)
575
613
  📧 **Contact**: [contact@usomnia.co.kr](mailto:contact@usomnia.co.kr)
@@ -3,6 +3,9 @@ set(CMAKE_VERBOSE_MAKEFILE ON)
3
3
 
4
4
  project(react_codegen_RNAttachmentKitSpec)
5
5
 
6
+ # Android 15+ 16KB 페이지 기기 지원 (Google Play: targetSdk 35+ 업데이트 필수). NDK r27 이하는 기본이 4KB 정렬이라 명시
7
+ string(APPEND CMAKE_SHARED_LINKER_FLAGS " -Wl,-z,max-page-size=16384")
8
+
6
9
  set(GENERATED_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/generated/source/codegen/jni")
7
10
 
8
11
  if(NOT COMMAND target_compile_reactnative_options)
@@ -46,10 +46,19 @@ if (project != rootProject) {
46
46
  android {
47
47
  namespace "com.rnattachmentkit.react"
48
48
  compileSdkVersion 34
49
+ // 16KB 페이지 지원: 앱과 같은 NDK (RN 템플릿의 rootProject.ext.ndkVersion, r27+ 권장)
50
+ if (rootProject.ext.has("ndkVersion")) {
51
+ ndkVersion rootProject.ext.ndkVersion
52
+ }
49
53
 
50
54
  defaultConfig {
51
55
  minSdkVersion 24
52
56
  targetSdkVersion 34
57
+ externalNativeBuild {
58
+ cmake {
59
+ arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
60
+ }
61
+ }
53
62
  }
54
63
  compileOptions {
55
64
  sourceCompatibility JavaVersion.VERSION_17
@@ -126,8 +135,8 @@ if (project != rootProject) {
126
135
  implementation "androidx.media3:media3-transformer:${media3_version}"
127
136
  implementation "androidx.media3:media3-effect:${media3_version}"
128
137
 
129
- // CameraX dependencies
130
- def camerax_version = "1.3.1"
138
+ // CameraX dependencies — 1.4.0+ 부터 libimage_processing_util_jni.so 가 16KB 정렬 (AAR 쪽과 같은 버전)
139
+ def camerax_version = "1.4.2"
131
140
  implementation "androidx.camera:camera-core:${camerax_version}"
132
141
  implementation "androidx.camera:camera-camera2:${camerax_version}"
133
142
  implementation "androidx.camera:camera-lifecycle:${camerax_version}"
@@ -59,10 +59,13 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
59
59
  val maxWidth: Int, val maxHeight: Int, val outputFormat: String,
60
60
  val videoOptions: com.rnattachmentkit.VideoExportOptions,
61
61
  val states: Map<String, com.rnattachmentkit.VideoEditState>,
62
- val showHUD: Boolean, val themeColor: Int?, val language: String
62
+ val showHUD: Boolean, val themeColor: Int?, val language: String,
63
+ val thumbnail: com.rnattachmentkit.PhotoThumbnail.Options?
63
64
  )
64
65
  private fun sendSnapshot() = SendSnapshot(maxWidth, maxHeight, outputFormat, videoOptions, HashMap(lastVideoEditStates),
65
- showVideoHUD, lastThemeColorInt, lastLanguageCode)
66
+ showVideoHUD, lastThemeColorInt, lastLanguageCode, thumbnailOptions)
67
+ /** 사진 썸네일 (GalleryOptions.thumbnail / EditorOptions.thumbnail). null 이면 만들지 않음 (기존과 같음) */
68
+ private var thumbnailOptions: com.rnattachmentkit.PhotoThumbnail.Options? = null
66
69
  /** 동영상 변환 진행 HUD — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만 (이벤트로 말풍선에 그리는 앱은 HUD 없이) */
67
70
  private var showVideoHUD = true
68
71
  /** 시트가 닫힐 때 넘겨받은 동영상 편집 상태 (원본 uri 문자열 → 품질·음소거) */
@@ -242,6 +245,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
242
245
 
243
246
  if (options.hasKey("maxWidth")) maxWidth = options.getInt("maxWidth")
244
247
  if (options.hasKey("maxHeight")) maxHeight = options.getInt("maxHeight")
248
+ thumbnailOptions = com.rnattachmentkit.PhotoThumbnail.parse(
249
+ if (options.hasKey("thumbnail") && !options.isNull("thumbnail")) options.getMap("thumbnail")?.toHashMap() else null
250
+ )
245
251
 
246
252
  editImagePromise = promise
247
253
 
@@ -350,6 +356,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
350
356
  videoOptions = com.rnattachmentkit.VideoExportOptions.fromMap(
351
357
  if (options.hasKey("video") && !options.isNull("video")) options.getMap("video")?.toHashMap() else null
352
358
  )
359
+ // 사진 썸네일 (동영상 포스터 규칙은 그대로 — video.posterFormat, 긴 변 512)
360
+ thumbnailOptions = com.rnattachmentkit.PhotoThumbnail.parse(
361
+ if (options.hasKey("thumbnail") && !options.isNull("thumbnail")) options.getMap("thumbnail")?.toHashMap() else null
362
+ )
353
363
  run {
354
364
  val async = options.hasKey("asyncProcessing") && !options.isNull("asyncProcessing") && options.getBoolean("asyncProcessing")
355
365
  val v = if (options.hasKey("video") && !options.isNull("video")) options.getMap("video") else null
@@ -528,6 +538,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
528
538
  }
529
539
 
530
540
  override fun openViewer(options: ReadableMap, promise: Promise) {
541
+ // 동영상 페이지는 라이선스 키가 있어야
542
+ if (!LicenseGate.isLicensed && options.hasKey("videos")) {
543
+ val va = options.getArray("videos")
544
+ val anyVideo = va != null && (0 until va.size()).any { i ->
545
+ va.getType(i) == com.facebook.react.bridge.ReadableType.Map && va.getMap(i)?.let { it.hasKey("uri") && !it.isNull("uri") && !it.getString("uri").isNullOrEmpty() } == true
546
+ }
547
+ if (anyVideo) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
548
+ }
531
549
  val activity = getCurrentActivity()
532
550
  if (activity == null) {
533
551
  promise.reject(E_NO_ACTIVITY, "Activity doesn't exist")
@@ -964,6 +982,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
964
982
  val showVideoHUD = snap.showHUD
965
983
  val lastThemeColorInt = snap.themeColor
966
984
  val lastLanguageCode = snap.language
985
+ val thumbnailOptions = snap.thumbnail
967
986
  val processingId = java.util.UUID.randomUUID().toString()
968
987
  val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
969
988
  val contentResolver = reactApplicationContext.contentResolver
@@ -1212,6 +1231,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1212
1231
  FileOutputStream(tempFile).use { out ->
1213
1232
  processedBitmap.compress(compressFormat, 85, out)
1214
1233
  }
1234
+ // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서 — 원본을 다시 읽지 않음
1235
+ val thumb = thumbnailOptions?.let {
1236
+ com.rnattachmentkit.PhotoThumbnail.make(processedBitmap, it, reactApplicationContext.cacheDir, currentIndex)
1237
+ }
1215
1238
 
1216
1239
  val finalWidth = processedBitmap.width
1217
1240
  val finalHeight = processedBitmap.height
@@ -1237,6 +1260,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1237
1260
  putString("originalUri", uri.toString())
1238
1261
  putInt("originalWidth", originalWidth)
1239
1262
  putInt("originalHeight", originalHeight)
1263
+ if (thumb != null) {
1264
+ putString("thumbnailUri", Uri.fromFile(thumb.file).toString())
1265
+ putInt("thumbnailWidth", thumb.width)
1266
+ putInt("thumbnailHeight", thumb.height)
1267
+ putDouble("thumbnailFileSize", thumb.fileSize.toDouble())
1268
+ }
1240
1269
  }
1241
1270
  }
1242
1271
  val imageInfo = createImageInfo()
@@ -1342,6 +1371,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1342
1371
  return
1343
1372
  }
1344
1373
  val o: Map<String, Any?> = options.toHashMap()
1374
+ // 동영상 항목은 라이선스 키가 있어야
1375
+ if (!LicenseGate.isLicensed && MediaGalleryItem.listFrom(o["items"]).any { it.isVideo }) {
1376
+ promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return
1377
+ }
1345
1378
  com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
1346
1379
  try {
1347
1380
  val systemDark = (activity.resources.configuration.uiMode and
@@ -1425,6 +1458,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1425
1458
  p["nextCursor"] as? String,
1426
1459
  p["hasMore"] as? Boolean ?: false
1427
1460
  )
1461
+ if (!LicenseGate.isLicensed && page.items.any { it.isVideo }) {
1462
+ promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return
1463
+ }
1428
1464
  com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
1429
1465
  mediaGalleries[galleryId]?.store?.appendExternal(page)
1430
1466
  promise.resolve(null)
@@ -1503,6 +1539,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1503
1539
  }
1504
1540
 
1505
1541
  override fun openMapViewer(options: ReadableMap, promise: Promise) {
1542
+ // 위치 기능은 라이선스 키가 있어야
1543
+ if (!LicenseGate.isLicensed) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
1506
1544
  val activity = getCurrentActivity() as? FragmentActivity
1507
1545
  if (activity == null) {
1508
1546
  promise.reject("NO_ACTIVITY", "Activity is not a FragmentActivity")
@@ -1585,6 +1623,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1585
1623
  */
1586
1624
  @ReactMethod
1587
1625
  override fun renderLocationThumbnail(options: ReadableMap, promise: Promise) {
1626
+ // 위치 기능은 라이선스 키가 있어야
1627
+ if (!LicenseGate.isLicensed) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
1588
1628
  fun str(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getString(key) else null
1589
1629
  fun num(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
1590
1630
  val lat = num("latitude")
@@ -1685,6 +1725,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1685
1725
  // ── AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림 ──
1686
1726
 
1687
1727
  override fun setAIConfig(config: ReadableMap, promise: Promise) {
1728
+ // 라이선스 키가 없어도 설정은 받아 둠 (AI 버튼은 보이고, 누르면 "라이선스 필요" 안내)
1729
+ if (!LicenseGate.isLicensed && config.hasKey("enabled") && !config.isNull("enabled") && config.getBoolean("enabled")) {
1730
+ android.util.Log.w("RNAttachmentKit", "AI needs a license key — the AI buttons show a license notice until init() gets a valid key")
1731
+ }
1688
1732
  AIBilling.apply(config.toHashMap())
1689
1733
  val ctx = reactApplicationContext
1690
1734
  fun emit(name: String, body: Map<String, Any>) {
@@ -1702,6 +1746,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1702
1746
  // ── 역지오코딩 (기기 Geocoder, 디스크 캐시) ──
1703
1747
 
1704
1748
  override fun reverseGeocode(options: ReadableMap, promise: Promise) {
1749
+ // 위치 기능은 라이선스 키가 있어야
1750
+ if (!LicenseGate.isLicensed) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
1705
1751
  val lat = if (options.hasKey("latitude") && !options.isNull("latitude")) options.getDouble("latitude") else Double.NaN
1706
1752
  val lng = if (options.hasKey("longitude") && !options.isNull("longitude")) options.getDouble("longitude") else Double.NaN
1707
1753
  val lang = if (options.hasKey("languageCode") && !options.isNull("languageCode")) options.getString("languageCode") else null
@@ -58,10 +58,13 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
58
58
  val maxWidth: Int, val maxHeight: Int, val outputFormat: String,
59
59
  val videoOptions: com.rnattachmentkit.VideoExportOptions,
60
60
  val states: Map<String, com.rnattachmentkit.VideoEditState>,
61
- val showHUD: Boolean, val themeColor: Int?, val language: String
61
+ val showHUD: Boolean, val themeColor: Int?, val language: String,
62
+ val thumbnail: com.rnattachmentkit.PhotoThumbnail.Options?
62
63
  )
63
64
  private fun sendSnapshot() = SendSnapshot(maxWidth, maxHeight, outputFormat, videoOptions, HashMap(lastVideoEditStates),
64
- showVideoHUD, lastThemeColorInt, lastLanguageCode)
65
+ showVideoHUD, lastThemeColorInt, lastLanguageCode, thumbnailOptions)
66
+ /** 사진 썸네일 (GalleryOptions.thumbnail / EditorOptions.thumbnail). null 이면 만들지 않음 (기존과 같음) */
67
+ private var thumbnailOptions: com.rnattachmentkit.PhotoThumbnail.Options? = null
65
68
  /** 동영상 변환 진행 HUD — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만 (이벤트로 말풍선에 그리는 앱은 HUD 없이) */
66
69
  private var showVideoHUD = true
67
70
  /** 시트가 닫힐 때 넘겨받은 동영상 편집 상태 (원본 uri 문자열 → 품질·음소거) */
@@ -180,6 +183,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
180
183
 
181
184
  if (options.hasKey("maxWidth")) maxWidth = options.getInt("maxWidth")
182
185
  if (options.hasKey("maxHeight")) maxHeight = options.getInt("maxHeight")
186
+ thumbnailOptions = com.rnattachmentkit.PhotoThumbnail.parse(
187
+ if (options.hasKey("thumbnail") && !options.isNull("thumbnail")) options.getMap("thumbnail")?.toHashMap() else null
188
+ )
183
189
 
184
190
  editImagePromise = promise
185
191
 
@@ -288,6 +294,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
288
294
  videoOptions = com.rnattachmentkit.VideoExportOptions.fromMap(
289
295
  if (options.hasKey("video") && !options.isNull("video")) options.getMap("video")?.toHashMap() else null
290
296
  )
297
+ // 사진 썸네일 (동영상 포스터 규칙은 그대로 — video.posterFormat, 긴 변 512)
298
+ thumbnailOptions = com.rnattachmentkit.PhotoThumbnail.parse(
299
+ if (options.hasKey("thumbnail") && !options.isNull("thumbnail")) options.getMap("thumbnail")?.toHashMap() else null
300
+ )
291
301
  run {
292
302
  val async = options.hasKey("asyncProcessing") && !options.isNull("asyncProcessing") && options.getBoolean("asyncProcessing")
293
303
  val v = if (options.hasKey("video") && !options.isNull("video")) options.getMap("video") else null
@@ -536,6 +546,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
536
546
 
537
547
  @ReactMethod
538
548
  override fun openViewer(options: ReadableMap, promise: Promise) {
549
+ // 동영상 페이지는 라이선스 키가 있어야
550
+ if (!LicenseGate.isLicensed && options.hasKey("videos")) {
551
+ val va = options.getArray("videos")
552
+ val anyVideo = va != null && (0 until va.size()).any { i ->
553
+ va.getType(i) == com.facebook.react.bridge.ReadableType.Map && va.getMap(i)?.let { it.hasKey("uri") && !it.isNull("uri") && !it.getString("uri").isNullOrEmpty() } == true
554
+ }
555
+ if (anyVideo) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
556
+ }
539
557
  val activity = getCurrentActivity()
540
558
  if (activity == null) {
541
559
  promise.reject(E_NO_ACTIVITY, "Activity doesn't exist")
@@ -948,6 +966,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
948
966
  val showVideoHUD = snap.showHUD
949
967
  val lastThemeColorInt = snap.themeColor
950
968
  val lastLanguageCode = snap.language
969
+ val thumbnailOptions = snap.thumbnail
951
970
  val processingId = java.util.UUID.randomUUID().toString()
952
971
  val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
953
972
  val contentResolver = reactApplicationContext.contentResolver
@@ -1196,6 +1215,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1196
1215
  FileOutputStream(tempFile).use { out ->
1197
1216
  processedBitmap.compress(compressFormat, 85, out)
1198
1217
  }
1218
+ // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서 — 원본을 다시 읽지 않음
1219
+ val thumb = thumbnailOptions?.let {
1220
+ com.rnattachmentkit.PhotoThumbnail.make(processedBitmap, it, reactApplicationContext.cacheDir, currentIndex)
1221
+ }
1199
1222
 
1200
1223
  val finalWidth = processedBitmap.width
1201
1224
  val finalHeight = processedBitmap.height
@@ -1221,6 +1244,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1221
1244
  putString("originalUri", uri.toString())
1222
1245
  putInt("originalWidth", originalWidth)
1223
1246
  putInt("originalHeight", originalHeight)
1247
+ if (thumb != null) {
1248
+ putString("thumbnailUri", Uri.fromFile(thumb.file).toString())
1249
+ putInt("thumbnailWidth", thumb.width)
1250
+ putInt("thumbnailHeight", thumb.height)
1251
+ putDouble("thumbnailFileSize", thumb.fileSize.toDouble())
1252
+ }
1224
1253
  }
1225
1254
  }
1226
1255
  val imageInfo = createImageInfo()
@@ -1327,6 +1356,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1327
1356
  return
1328
1357
  }
1329
1358
  val o: Map<String, Any?> = options.toHashMap()
1359
+ // 동영상 항목은 라이선스 키가 있어야
1360
+ if (!LicenseGate.isLicensed && MediaGalleryItem.listFrom(o["items"]).any { it.isVideo }) {
1361
+ promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return
1362
+ }
1330
1363
  com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
1331
1364
  try {
1332
1365
  val systemDark = (activity.resources.configuration.uiMode and
@@ -1412,6 +1445,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1412
1445
  p["nextCursor"] as? String,
1413
1446
  p["hasMore"] as? Boolean ?: false
1414
1447
  )
1448
+ if (!LicenseGate.isLicensed && page.items.any { it.isVideo }) {
1449
+ promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return
1450
+ }
1415
1451
  com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
1416
1452
  mediaGalleries[galleryId]?.store?.appendExternal(page)
1417
1453
  promise.resolve(null)
@@ -1488,6 +1524,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1488
1524
 
1489
1525
  @ReactMethod
1490
1526
  fun openMapViewer(options: ReadableMap, promise: Promise) {
1527
+ // 위치 기능은 라이선스 키가 있어야
1528
+ if (!LicenseGate.isLicensed) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
1491
1529
  val activity = getCurrentActivity() as? FragmentActivity
1492
1530
  if (activity == null) {
1493
1531
  promise.reject("NO_ACTIVITY", "Activity is not a FragmentActivity")
@@ -1571,6 +1609,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1571
1609
  */
1572
1610
  @ReactMethod
1573
1611
  fun renderLocationThumbnail(options: ReadableMap, promise: Promise) {
1612
+ // 위치 기능은 라이선스 키가 있어야
1613
+ if (!LicenseGate.isLicensed) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
1574
1614
  fun str(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getString(key) else null
1575
1615
  fun num(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
1576
1616
  val lat = num("latitude")
@@ -1672,6 +1712,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1672
1712
 
1673
1713
  @ReactMethod
1674
1714
  fun setAIConfig(config: ReadableMap, promise: Promise) {
1715
+ // 라이선스 키가 없어도 설정은 받아 둠 (AI 버튼은 보이고, 누르면 "라이선스 필요" 안내)
1716
+ if (!LicenseGate.isLicensed && config.hasKey("enabled") && !config.isNull("enabled") && config.getBoolean("enabled")) {
1717
+ android.util.Log.w("RNAttachmentKit", "AI needs a license key — the AI buttons show a license notice until init() gets a valid key")
1718
+ }
1675
1719
  AIBilling.apply(config.toHashMap())
1676
1720
  val ctx = reactApplicationContext
1677
1721
  fun emit(name: String, body: Map<String, Any>) {
@@ -1690,6 +1734,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1690
1734
 
1691
1735
  @ReactMethod
1692
1736
  fun reverseGeocode(options: ReadableMap, promise: Promise) {
1737
+ // 위치 기능은 라이선스 키가 있어야
1738
+ if (!LicenseGate.isLicensed) { promise.reject(LicenseGate.ERROR_CODE, LicenseGate.ERROR_MESSAGE); return }
1693
1739
  val lat = if (options.hasKey("latitude") && !options.isNull("latitude")) options.getDouble("latitude") else Double.NaN
1694
1740
  val lng = if (options.hasKey("longitude") && !options.isNull("longitude")) options.getDouble("longitude") else Double.NaN
1695
1741
  val lang = if (options.hasKey("languageCode") && !options.isNull("languageCode")) options.getString("languageCode") else null