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
@@ -39,6 +39,33 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
39
39
  private var maxWidth: Int = 1024
40
40
  private var maxHeight: Int = 1024
41
41
  private var outputFormat: String = "webp" // Default to webp
42
+ // ─── 동영상 (openGallery mediaType / video 옵션) ───
43
+ private var mediaType: String = "photo"
44
+ private var videoOptions = com.rnattachmentkit.VideoExportOptions()
45
+ /** onSelectionChange 에 실어 보낼 maxSelection (예전엔 10 고정) */
46
+ private var lastMaxSelection: Int = 10
47
+ private var lastThemeColorInt: Int? = null
48
+ private var lastLanguageCode: String = "en"
49
+ @Volatile private var currentVideoExporter: com.rnattachmentkit.VideoExporter? = null
50
+ @Volatile private var videoCancelled = false
51
+ /** 진행 중인 전송 처리 (processingId → 작업). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음 */
52
+ private val activeProcessingJobs = java.util.concurrent.ConcurrentHashMap<String, com.rnattachmentkit.ProcessingJob>()
53
+ /** 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때) */
54
+ @Volatile private var lastProcessingId: String? = null
55
+
56
+ /** 보낸 순간의 설정 — 처리 중에 갤러리를 다시 열면 모듈 필드가 새 호출 값으로 바뀌므로, 처리는 이 사본만 씀 */
57
+ private class SendSnapshot(
58
+ val maxWidth: Int, val maxHeight: Int, val outputFormat: String,
59
+ val videoOptions: com.rnattachmentkit.VideoExportOptions,
60
+ val states: Map<String, com.rnattachmentkit.VideoEditState>,
61
+ val showHUD: Boolean, val themeColor: Int?, val language: String
62
+ )
63
+ private fun sendSnapshot() = SendSnapshot(maxWidth, maxHeight, outputFormat, videoOptions, HashMap(lastVideoEditStates),
64
+ showVideoHUD, lastThemeColorInt, lastLanguageCode)
65
+ /** 동영상 변환 진행 HUD — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만 (이벤트로 말풍선에 그리는 앱은 HUD 없이) */
66
+ private var showVideoHUD = true
67
+ /** 시트가 닫힐 때 넘겨받은 동영상 편집 상태 (원본 uri 문자열 → 품질·음소거) */
68
+ private var lastVideoEditStates: Map<String, com.rnattachmentkit.VideoEditState> = emptyMap()
42
69
 
43
70
  @ReactMethod
44
71
  fun addListener(eventName: String) {
@@ -255,6 +282,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
255
282
  false
256
283
  }
257
284
 
285
+ // 동영상: mediaType "photo"(기본) | "video" | "all", video: VideoOptions
286
+ mediaType = (if (options.hasKey("mediaType") && !options.isNull("mediaType")) options.getString("mediaType") else null)
287
+ ?.lowercase()?.takeIf { it == "video" || it == "all" } ?: "photo"
288
+ videoOptions = com.rnattachmentkit.VideoExportOptions.fromMap(
289
+ if (options.hasKey("video") && !options.isNull("video")) options.getMap("video")?.toHashMap() else null
290
+ )
291
+ run {
292
+ val async = options.hasKey("asyncProcessing") && !options.isNull("asyncProcessing") && options.getBoolean("asyncProcessing")
293
+ val v = if (options.hasKey("video") && !options.isNull("video")) options.getMap("video") else null
294
+ showVideoHUD = if (v != null && v.hasKey("showProgressHUD") && !v.isNull("showProgressHUD")) v.getBoolean("showProgressHUD") else !async
295
+ }
296
+ lastMaxSelection = maxSelection
297
+ TurboImagePickerConfig.videoOptions = videoOptions
298
+ lastLanguageCode = languageCode
299
+ lastThemeColorInt = try { themeColor?.let { android.graphics.Color.parseColor(it) } } catch (_: Exception) { null }
300
+
258
301
  val fragmentActivity = activity as? FragmentActivity
259
302
  if (fragmentActivity == null) {
260
303
  promise.reject(E_NO_ACTIVITY, "Activity is not a FragmentActivity")
@@ -283,6 +326,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
283
326
  } else {
284
327
  Manifest.permission.READ_EXTERNAL_STORAGE
285
328
  }
329
+ // Android 13+ 에서 동영상도 보이면 READ_MEDIA_VIDEO 를 같이 요청
330
+ val mediaPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && mediaType != "photo") {
331
+ arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO)
332
+ } else {
333
+ arrayOf(permission)
334
+ }
286
335
 
287
336
  val showSheet = {
288
337
  fragmentActivity.runOnUiThread {
@@ -299,7 +348,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
299
348
  options.getDouble("attachMenuMinBottomMargin").toFloat() else com.rnattachmentkit.AttachMenuBarView.BOTTOM_MARGIN_DP.toFloat(),
300
349
  // 위치 보내기 때 지도 이미지 캡처 여부. 기본 true (기존 그대로). false 면 이미지 없이 위치 정보만
301
350
  captureLocationImage = !(options.hasKey("captureLocationImage") && !options.isNull("captureLocationImage")) ||
302
- options.getBoolean("captureLocationImage")
351
+ options.getBoolean("captureLocationImage"),
352
+ // 동영상 (mediaType "video"/"all" 일 때 그리드에 동영상, 길이 제한)
353
+ mediaType = mediaType,
354
+ maxVideoDurationSec = videoOptions.maxDurationSec
303
355
  )
304
356
  // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
305
357
  bottomSheet.setOnLocationSelectedListener { loc ->
@@ -345,6 +397,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
345
397
  settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
346
398
  }
347
399
  bottomSheet.setOnImagesSelectedListener { uris ->
400
+ lastVideoEditStates = HashMap(bottomSheet.videoEditStates)
348
401
  val result = WritableNativeArray()
349
402
  for (uri in uris) {
350
403
  result.pushString(uri.toString())
@@ -370,7 +423,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
370
423
  }
371
424
  }
372
425
 
373
- if (ContextCompat.checkSelfPermission(fragmentActivity, permission) == PackageManager.PERMISSION_GRANTED) {
426
+ if (mediaPermissions.all { ContextCompat.checkSelfPermission(fragmentActivity, it) == PackageManager.PERMISSION_GRANTED }) {
374
427
  showSheet()
375
428
  } else {
376
429
  val permissionAwareActivity = fragmentActivity as? PermissionAwareActivity
@@ -385,21 +438,19 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
385
438
  showSheet()
386
439
  return
387
440
  }
388
- arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
441
+ mediaPermissions + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
389
442
  } else {
390
- arrayOf(permission)
443
+ mediaPermissions
391
444
  }
392
445
 
393
446
  permissionAwareActivity.requestPermissions(permissionsToRequest, 100, object : PermissionListener {
394
447
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Boolean {
395
448
  if (requestCode == 100 && grantResults != null) {
396
449
  val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
397
- grantResults.isNotEmpty() && (
398
- grantResults[0] == PackageManager.PERMISSION_GRANTED ||
399
- (grantResults.size > 1 && grantResults[1] == PackageManager.PERMISSION_GRANTED)
400
- )
450
+ // 14+: 전체 허용이든 "일부 선택"이든 하나라도 허용이면 진행
451
+ grantResults.any { it == PackageManager.PERMISSION_GRANTED }
401
452
  } else {
402
- grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
453
+ grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
403
454
  }
404
455
 
405
456
  if (isGranted) {
@@ -462,6 +513,15 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
462
513
  promise.resolve(null)
463
514
  }
464
515
 
516
+ /** 동영상 변환 취소 (HUD 없이 앱이 말풍선에서). index -1 = 남은 동영상 전부. 진행 중인 처리가 없으면 false */
517
+ @ReactMethod
518
+ fun cancelVideoProcessing(index: Double, processingId: String, promise: Promise) {
519
+ val job = (processingId.ifEmpty { lastProcessingId })?.let { activeProcessingJobs[it] }
520
+ if (job == null) { promise.resolve(false); return }
521
+ job.cancel(if (index >= 0) index.toInt() else null)
522
+ promise.resolve(true)
523
+ }
524
+
465
525
  @ReactMethod
466
526
  override fun closeGallery(promise: Promise) {
467
527
  val currentActivity = currentActivity
@@ -600,6 +660,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
600
660
  putStringArrayList("placeholderImages", placeholders)
601
661
  }
602
662
  }
663
+ // 동영상 페이지 (뷰어 동영상 작업 지시서 V1): videos[i] = { uri, cacheFile? } 또는 null(사진).
664
+ // images[i] 는 그 동영상의 포스터. 이 오버레이 뷰어에서만 재생합니다.
665
+ if (options.hasKey("videos")) {
666
+ val videoArray = options.getArray("videos")
667
+ if (videoArray != null) {
668
+ val uris = ArrayList<String>()
669
+ val caches = ArrayList<String>()
670
+ for (i in 0 until videoArray.size()) {
671
+ val m = if (videoArray.getType(i) == com.facebook.react.bridge.ReadableType.Map) videoArray.getMap(i) else null
672
+ uris.add(if (m != null && m.hasKey("uri") && !m.isNull("uri")) m.getString("uri") ?: "" else "")
673
+ caches.add(if (m != null && m.hasKey("cacheFile") && !m.isNull("cacheFile")) m.getString("cacheFile") ?: "" else "")
674
+ }
675
+ putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_URIS, uris)
676
+ putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_CACHE_FILES, caches)
677
+ }
678
+ }
603
679
  // 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
604
680
  // 네이티브 쪽 로케일 리소스(values-ko/ja/zh, 그 외 en)가 자동 적용됩니다.
605
681
  putBoolean("showActionButtons", !options.hasKey("showActionButtons") || options.getBoolean("showActionButtons"))
@@ -792,9 +868,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
792
868
  return
793
869
  }
794
870
 
871
+ val snap = sendSnapshot()
795
872
  moduleScope.launch {
796
873
  try {
797
- val results = processImages(uris)
874
+ val results = processImages(uris, snap)
798
875
  if (results.isNotEmpty()) {
799
876
  promise.resolve(results[0])
800
877
  } else {
@@ -838,7 +915,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
838
915
  // Process images asynchronously
839
916
  val eventMap = com.facebook.react.bridge.WritableNativeMap()
840
917
  eventMap.putInt("selectedCount", uris.size)
841
- eventMap.putInt("maxSelection", 10)
918
+ eventMap.putInt("maxSelection", lastMaxSelection)
842
919
  try {
843
920
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
844
921
  .emit("onSelectionChange", eventMap)
@@ -846,25 +923,183 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
846
923
  // Ignore
847
924
  }
848
925
 
926
+ val snap = sendSnapshot()
849
927
  moduleScope.launch {
850
928
  try {
851
- val results = processImages(uris)
929
+ val results = processImages(uris, snap)
852
930
  val array = com.facebook.react.bridge.Arguments.createArray()
853
931
  results.forEach { array.pushMap(it) }
854
932
  actualPromise.resolve(array)
933
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
934
+ actualPromise.reject("CANCELLED", "Video export cancelled")
855
935
  } catch (e: Exception) {
856
936
  actualPromise.reject(E_FAILED_TO_PICK, "Failed to process images: ${e.message}", e)
857
937
  }
858
938
  }
859
939
  }
860
940
 
861
- private suspend fun processImages(uris: List<Uri>): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
941
+ private suspend fun processImages(uris: List<Uri>, snap: SendSnapshot): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
942
+ // 보낸 순간의 설정 (모듈 필드와 같은 이름으로 가림 — 아래 코드는 그대로)
943
+ val maxWidth = snap.maxWidth
944
+ val maxHeight = snap.maxHeight
945
+ val outputFormat = snap.outputFormat
946
+ val videoOptions = snap.videoOptions
947
+ val lastVideoEditStates = snap.states
948
+ val showVideoHUD = snap.showHUD
949
+ val lastThemeColorInt = snap.themeColor
950
+ val lastLanguageCode = snap.language
951
+ val processingId = java.util.UUID.randomUUID().toString()
862
952
  val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
863
953
  val contentResolver = reactApplicationContext.contentResolver
864
954
  val totalCount = uris.size
865
955
  var currentIndex = 0
866
956
 
957
+ // 동영상이 섞여 있으면 진행 HUD 를 띄우고, 동영상은 VideoExporter 로 (사진 처리와 같은 루프)
958
+ val isVideoUri: (Uri) -> Boolean = { u ->
959
+ u.toString().contains("/video/") || (try { contentResolver.getType(u)?.startsWith("video/") == true } catch (_: Exception) { false })
960
+ }
961
+ val hasVideo = uris.any(isVideoUri)
962
+ videoCancelled = false
963
+ val job = com.rnattachmentkit.ProcessingJob()
964
+ activeProcessingJobs[processingId] = job
965
+ lastProcessingId = processingId
966
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
967
+ try {
968
+ val items = Arguments.createArray()
969
+ uris.forEachIndexed { i, u ->
970
+ val it0 = if (isVideoUri(u)) {
971
+ val st = lastVideoEditStates[u.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
972
+ com.rnattachmentkit.MediaProcessing.videoItem(reactApplicationContext, i, u, st, videoOptions)
973
+ } else {
974
+ com.rnattachmentkit.MediaProcessing.imageItem(reactApplicationContext, i, u, maxWidth, maxHeight)
975
+ }
976
+ items.pushMap(Arguments.createMap().apply {
977
+ putInt("index", it0.index)
978
+ putString("mediaType", if (it0.isVideo) "video" else "image")
979
+ putString("originalUri", it0.originalUri)
980
+ putInt("width", it0.width)
981
+ putInt("height", it0.height)
982
+ it0.durationSec?.let { d -> putDouble("duration", d) }
983
+ it0.thumbnailUri?.let { t ->
984
+ putString("thumbnailUri", t)
985
+ putInt("thumbnailWidth", it0.thumbnailWidth)
986
+ putInt("thumbnailHeight", it0.thumbnailHeight)
987
+ }
988
+ })
989
+ }
990
+ emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
991
+ putString("processingId", processingId)
992
+ putInt("total", totalCount)
993
+ putArray("items", items)
994
+ })
995
+ } catch (e: Exception) {
996
+ android.util.Log.w("RNAttachmentKit", "onProcessingStart failed: ${e.message}")
997
+ }
998
+ var hud: com.rnattachmentkit.VideoExportHUD? = null
999
+ if (hasVideo && showVideoHUD) {
1000
+ withContext(Dispatchers.Main) {
1001
+ val act = getCurrentActivity() ?: return@withContext
1002
+ hud = com.rnattachmentkit.VideoExportHUD(act, lastThemeColorInt, lastLanguageCode).also { h ->
1003
+ h.onCancel = {
1004
+ videoCancelled = true
1005
+ currentVideoExporter?.cancel()
1006
+ }
1007
+ h.show()
1008
+ }
1009
+ }
1010
+ }
1011
+ val videoFiles = mutableListOf<File>()
1012
+
1013
+ try {
867
1014
  for (uri in uris) {
1015
+ if (videoCancelled) throw com.rnattachmentkit.VideoExporter.CancelledException()
1016
+ if (isVideoUri(uri)) {
1017
+ val idx = currentIndex
1018
+ val exporter = com.rnattachmentkit.VideoExporter(reactApplicationContext)
1019
+ currentVideoExporter = exporter
1020
+ val cancelledMap = {
1021
+ WritableNativeMap().apply {
1022
+ putString("originalUri", uri.toString())
1023
+ putString("mediaType", "video")
1024
+ putString("error", "CANCELLED")
1025
+ putBoolean("cancelled", true)
1026
+ }
1027
+ }
1028
+ val resultMap = try {
1029
+ // 앱이 이미 취소한 동영상은 건너뜀
1030
+ if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1031
+ job.begin(idx, exporter)
1032
+ val state = lastVideoEditStates[uri.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1033
+ val throttle = com.rnattachmentkit.ProgressThrottle()
1034
+ val onProgress: (Float) -> Unit = { p ->
1035
+ hud?.let { h -> android.os.Handler(android.os.Looper.getMainLooper()).post { h.update(p, idx, totalCount) } }
1036
+ if (throttle.shouldSend(p)) emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1037
+ putInt("index", idx); putInt("total", totalCount); putDouble("progress", p.toDouble())
1038
+ putString("processingId", processingId)
1039
+ })
1040
+ }
1041
+ // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1042
+ // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1043
+ val stop = { videoCancelled || job.isCancelled(idx) }
1044
+ val res = com.rnattachmentkit.VideoPrepareManager.takeReady(uri, state, onProgress, stop)
1045
+ ?: run {
1046
+ if (stop()) throw com.rnattachmentkit.VideoExporter.CancelledException()
1047
+ exporter.export(uri, state, videoOptions, reactApplicationContext.cacheDir, "turbo_video_${System.currentTimeMillis()}_$idx", onProgress)
1048
+ }
1049
+ withContext(Dispatchers.Main) { hud?.update(1f, idx, totalCount) }
1050
+ // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1051
+ if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1052
+ videoFiles += res.file
1053
+ res.thumbnailFile?.let { videoFiles += it }
1054
+ WritableNativeMap().apply {
1055
+ putString("uri", Uri.fromFile(res.file).toString())
1056
+ putString("originalUri", uri.toString())
1057
+ putString("mediaType", "video")
1058
+ putString("type", "video/mp4")
1059
+ putInt("width", res.width)
1060
+ putInt("height", res.height)
1061
+ putDouble("duration", res.durationMs / 1000.0)
1062
+ putDouble("fileSize", res.fileSize.toDouble())
1063
+ putString("fileName", res.file.name)
1064
+ putString("fileExtension", "mp4")
1065
+ putString("videoCodec", res.codec)
1066
+ putInt("videoBitrate", res.bitrate)
1067
+ putBoolean("edited", res.edited)
1068
+ if (res.thumbnailFile != null) {
1069
+ putString("thumbnailUri", Uri.fromFile(res.thumbnailFile).toString())
1070
+ putInt("thumbnailWidth", res.thumbnailWidth)
1071
+ putInt("thumbnailHeight", res.thumbnailHeight)
1072
+ }
1073
+ }
1074
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1075
+ // 앱이 이 동영상만 취소 → 나머지는 계속. HUD 취소면 전체 취소
1076
+ if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else throw e
1077
+ } catch (e: Exception) {
1078
+ // 취소하면서 변환기가 다른 예외로 끝날 수도 있음 → 취소로 처리
1079
+ if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else {
1080
+ android.util.Log.w("RNAttachmentKit", "video export failed: ${e.message}", e)
1081
+ WritableNativeMap().apply {
1082
+ putString("originalUri", uri.toString())
1083
+ putString("mediaType", "video")
1084
+ putString("error", e.message ?: "export failed")
1085
+ }
1086
+ }
1087
+ } finally {
1088
+ currentVideoExporter = null
1089
+ job.end()
1090
+ }
1091
+ results.add(resultMap)
1092
+ try {
1093
+ val copy = WritableNativeMap().apply { merge(resultMap) }
1094
+ reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1095
+ .emit("onImageProcessed", WritableNativeMap().apply {
1096
+ putInt("total", totalCount); putInt("index", idx); putMap("image", copy)
1097
+ putString("processingId", processingId)
1098
+ })
1099
+ } catch (_: Exception) {}
1100
+ currentIndex++
1101
+ continue
1102
+ }
868
1103
  try {
869
1104
  // 1. Get EXIF Orientation
870
1105
  var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
@@ -995,6 +1230,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
995
1230
  val eventMap = WritableNativeMap().apply {
996
1231
  putInt("total", totalCount)
997
1232
  putInt("index", currentIndex)
1233
+ putString("processingId", processingId)
998
1234
  putMap("image", createImageInfo())
999
1235
  }
1000
1236
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@@ -1010,6 +1246,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1010
1246
  continue
1011
1247
  }
1012
1248
  }
1249
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1250
+ // 취소: 지금까지 만든 동영상 파일은 지움
1251
+ videoFiles.forEach { try { it.delete() } catch (_: Exception) {} }
1252
+ throw e
1253
+ } finally {
1254
+ activeProcessingJobs.remove(processingId)
1255
+ if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
1256
+ }
1013
1257
 
1014
1258
  results
1015
1259
  }
@@ -1289,6 +1533,13 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1289
1533
  )
1290
1534
  if (rect != null) f.sourceRectProvider = { mapViewerSourceRect }
1291
1535
  var settled = false
1536
+ f.onWillClose = { ms ->
1537
+ try {
1538
+ reactApplicationContext
1539
+ .getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1540
+ .emit("onMapViewerWillClose", com.facebook.react.bridge.Arguments.createMap().apply { putInt("durationMs", ms) })
1541
+ } catch (_: Exception) {}
1542
+ }
1292
1543
  f.onClosed = {
1293
1544
  if (!settled) { settled = true; promise.resolve(null) }
1294
1545
  mapViewerSourceRect = null
@@ -1417,6 +1668,24 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1417
1668
  }
1418
1669
  }
1419
1670
 
1671
+ // ── AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림 ──
1672
+
1673
+ @ReactMethod
1674
+ fun setAIConfig(config: ReadableMap, promise: Promise) {
1675
+ AIBilling.apply(config.toHashMap())
1676
+ val ctx = reactApplicationContext
1677
+ fun emit(name: String, body: Map<String, Any>) {
1678
+ // 앱이 내려가는 중이면 조용히 무시
1679
+ runCatching {
1680
+ ctx.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1681
+ .emit(name, com.facebook.react.bridge.Arguments.makeNativeMap(body))
1682
+ }
1683
+ }
1684
+ AIBilling.onUsage = { emit("onAIUsage", it) }
1685
+ AIBilling.onChargeRequired = { emit("onAIChargeRequired", it) }
1686
+ promise.resolve(null)
1687
+ }
1688
+
1420
1689
  // ── 역지오코딩 (기기 Geocoder, 디스크 캐시) ──
1421
1690
 
1422
1691
  @ReactMethod
@@ -32,6 +32,11 @@ RCT_EXTERN_METHOD(openViewer:(NSDictionary *)options
32
32
  RCT_EXTERN_METHOD(closeGallery:(RCTPromiseResolveBlock)resolve
33
33
  reject:(RCTPromiseRejectBlock)reject)
34
34
 
35
+ RCT_EXTERN_METHOD(cancelVideoProcessing:(nonnull NSNumber *)index
36
+ processingId:(NSString *)processingId
37
+ resolve:(RCTPromiseResolveBlock)resolve
38
+ reject:(RCTPromiseRejectBlock)reject)
39
+
35
40
  RCT_EXTERN_METHOD(injectImageCache:(NSString *)urlString
36
41
  localPath:(NSString *)localPath
37
42
  resolve:(RCTPromiseResolveBlock)resolve
@@ -57,6 +62,10 @@ RCT_EXTERN_METHOD(reverseGeocode:(NSDictionary *)options
57
62
  resolve:(RCTPromiseResolveBlock)resolve
58
63
  reject:(RCTPromiseRejectBlock)reject)
59
64
 
65
+ RCT_EXTERN_METHOD(setAIConfig:(NSDictionary *)config
66
+ resolve:(RCTPromiseResolveBlock)resolve
67
+ reject:(RCTPromiseRejectBlock)reject)
68
+
60
69
  RCT_EXTERN_METHOD(clearReverseGeocodeCache:(RCTPromiseResolveBlock)resolve
61
70
  reject:(RCTPromiseRejectBlock)reject)
62
71
 
@@ -114,6 +123,11 @@ RCT_EXTERN_METHOD(openViewer:(NSDictionary *)options
114
123
  RCT_EXTERN_METHOD(closeGallery:(RCTPromiseResolveBlock)resolve
115
124
  reject:(RCTPromiseRejectBlock)reject)
116
125
 
126
+ RCT_EXTERN_METHOD(cancelVideoProcessing:(nonnull NSNumber *)index
127
+ processingId:(NSString *)processingId
128
+ resolve:(RCTPromiseResolveBlock)resolve
129
+ reject:(RCTPromiseRejectBlock)reject)
130
+
117
131
  RCT_EXTERN_METHOD(injectImageCache:(NSString *)urlString
118
132
  localPath:(NSString *)localPath
119
133
  resolve:(RCTPromiseResolveBlock)resolve
@@ -139,6 +153,10 @@ RCT_EXTERN_METHOD(reverseGeocode:(NSDictionary *)options
139
153
  resolve:(RCTPromiseResolveBlock)resolve
140
154
  reject:(RCTPromiseRejectBlock)reject)
141
155
 
156
+ RCT_EXTERN_METHOD(setAIConfig:(NSDictionary *)config
157
+ resolve:(RCTPromiseResolveBlock)resolve
158
+ reject:(RCTPromiseRejectBlock)reject)
159
+
142
160
  RCT_EXTERN_METHOD(clearReverseGeocodeCache:(RCTPromiseResolveBlock)resolve
143
161
  reject:(RCTPromiseRejectBlock)reject)
144
162