react-native-attachment-kit 1.0.2 → 1.0.6

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 (147) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +446 -206
  3. package/android/build.gradle +17 -0
  4. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  5. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +587 -22
  6. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +592 -21
  7. package/ios/RNAttachmentKit.m +62 -0
  8. package/ios/RNAttachmentKit.swift +725 -29
  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 +51 -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 +51876 -18835
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +310 -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 +310 -5
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~dark.webp +0 -0
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~light.webp +0 -0
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~dark.webp +0 -0
  30. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~light.webp +0 -0
  31. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~dark.webp +0 -0
  32. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~light.webp +0 -0
  33. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~dark.webp +0 -0
  34. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~light.webp +0 -0
  35. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~dark.webp +0 -0
  36. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~light.webp +0 -0
  37. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  38. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  39. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  40. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  41. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  42. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  43. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  44. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  45. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  46. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  47. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  48. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  49. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  50. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~light.webp +0 -0
  51. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~dark.webp +0 -0
  52. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~light.webp +0 -0
  53. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  54. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +195 -63
  55. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  56. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  57. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +102 -10
  58. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
  59. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +51876 -18835
  60. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +310 -5
  61. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  62. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +310 -5
  63. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +51876 -18835
  64. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +310 -5
  65. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  66. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +310 -5
  67. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  68. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  69. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  70. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  71. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  72. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  73. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  74. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  75. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  76. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  77. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  78. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~dark.webp +0 -0
  79. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~light.webp +0 -0
  80. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~dark.webp +0 -0
  81. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~light.webp +0 -0
  82. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~dark.webp +0 -0
  83. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~light.webp +0 -0
  84. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~dark.webp +0 -0
  85. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~light.webp +0 -0
  86. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~dark.webp +0 -0
  87. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~light.webp +0 -0
  88. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  89. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  90. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  91. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  92. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  93. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  94. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  95. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  96. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  97. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  98. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  99. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  100. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  101. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~light.webp +0 -0
  102. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~dark.webp +0 -0
  103. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~light.webp +0 -0
  104. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  105. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +205 -73
  106. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  107. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  108. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  109. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  110. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  111. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  112. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  113. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  114. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  115. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  116. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_back~dark.webp +0 -0
  117. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_back~light.webp +0 -0
  118. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_blur~dark.webp +0 -0
  119. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_blur~light.webp +0 -0
  120. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check_small~dark.webp +0 -0
  121. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check_small~light.webp +0 -0
  122. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check~dark.webp +0 -0
  123. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check~light.webp +0 -0
  124. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_close~dark.webp +0 -0
  125. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_close~light.webp +0 -0
  126. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  127. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  128. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  129. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  130. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  131. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  132. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  133. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  134. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  135. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  136. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  137. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  138. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  139. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_text~light.webp +0 -0
  140. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_text_add~dark.webp +0 -0
  141. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_text_add~light.webp +0 -0
  142. package/package.json +1 -1
  143. package/react-native-attachment-kit.podspec +1 -1
  144. package/src/NativeAttachmentKit.ts +15 -0
  145. package/src/index.d.ts +5 -56
  146. package/src/index.ts +301 -37
  147. package/src/types.ts +380 -11
@@ -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")
@@ -324,31 +367,107 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
324
367
  }
325
368
 
326
369
  pickImagesPromise = promise
370
+ // 이번 호출의 promise 를 붙잡아 둠 — 리스너가 모듈 필드를 읽으면, 시트가 열린 채 openGallery 를 다시 불렀을 때
371
+ // 이전 시트가 치워지며 오는 onDismiss 가 "새" promise 를 거부해 버림. 시트마다 자기 promise 를 한 번만 끝냄
372
+ val thisPromise = promise
373
+ var settled = false
374
+ val settle: ((Promise) -> Unit) -> Unit = { block ->
375
+ if (!settled) {
376
+ settled = true
377
+ if (pickImagesPromise === thisPromise) pickImagesPromise = null
378
+ block(thisPromise)
379
+ }
380
+ }
381
+ // 지난 호출이 남긴 오래된 임시 파일(24시간 지난 것) 정리 — 백그라운드
382
+ moduleScope.launch(Dispatchers.IO) {
383
+ try { TempFileCleaner.cleanStale(reactApplicationContext) } catch (_: Exception) {}
384
+ }
327
385
 
328
386
  val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
329
387
  Manifest.permission.READ_MEDIA_IMAGES
330
388
  } else {
331
389
  Manifest.permission.READ_EXTERNAL_STORAGE
332
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
+ }
333
397
 
334
398
  val showSheet = {
335
399
  fragmentActivity.runOnUiThread {
336
400
  try {
337
401
  val bottomSheet = ImagePickerBottomSheet.newInstance(
338
402
  maxSelection, languageCode, enableEditor, profileMode, maxWidth, maxHeight, themeColor,
339
- forceMultiMode = forceMultiMode
403
+ forceMultiMode = forceMultiMode,
404
+ // 첨부 메뉴(갤러리 / 위치 / 파일). 기본 false — 켜지 않으면 기존과 같음
405
+ showAttachMenu = options.hasKey("showAttachMenu") && options.getBoolean("showAttachMenu"),
406
+ // (Android) 첨부 메뉴 바 아래 여백 (dp). 없으면 기본 12 / 8
407
+ attachMenuSinkIntoNav = if (options.hasKey("attachMenuSinkIntoNav") && !options.isNull("attachMenuSinkIntoNav"))
408
+ options.getDouble("attachMenuSinkIntoNav").toFloat() else com.rnattachmentkit.AttachMenuBarView.SINK_INTO_NAV_DP.toFloat(),
409
+ attachMenuMinBottomMargin = if (options.hasKey("attachMenuMinBottomMargin") && !options.isNull("attachMenuMinBottomMargin"))
410
+ options.getDouble("attachMenuMinBottomMargin").toFloat() else com.rnattachmentkit.AttachMenuBarView.BOTTOM_MARGIN_DP.toFloat(),
411
+ // 위치 보내기 때 지도 이미지 캡처 여부. 기본 true (기존 그대로). false 면 이미지 없이 위치 정보만
412
+ captureLocationImage = !(options.hasKey("captureLocationImage") && !options.isNull("captureLocationImage")) ||
413
+ options.getBoolean("captureLocationImage"),
414
+ // 동영상 (mediaType "video"/"all" 일 때 그리드에 동영상, 길이 제한)
415
+ mediaType = mediaType,
416
+ maxVideoDurationSec = videoOptions.maxDurationSec
340
417
  )
418
+ // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
419
+ bottomSheet.setOnLocationSelectedListener { loc ->
420
+ val body = com.facebook.react.bridge.Arguments.createMap()
421
+ for ((k, v) in loc) {
422
+ when (v) {
423
+ is Int -> body.putInt(k, v)
424
+ is Long -> body.putDouble(k, v.toDouble())
425
+ is Double -> body.putDouble(k, v)
426
+ is Float -> body.putDouble(k, v.toDouble())
427
+ is Boolean -> body.putBoolean(k, v)
428
+ is String -> body.putString(k, v)
429
+ }
430
+ }
431
+ emitGalleryEvent("onLocationSelected", body)
432
+ settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
433
+ }
434
+ // 파일 탭에서 보내기 → onFilesSelected 이벤트 { files: [{uri, path, name, size, mimeType}], failed: [{name, error}] }, openGallery 는 빈 배열
435
+ bottomSheet.setOnFilesResultListener { files, failed ->
436
+ val arr = com.facebook.react.bridge.Arguments.createArray()
437
+ for (file in files) {
438
+ val m = com.facebook.react.bridge.Arguments.createMap()
439
+ for ((k, v) in file) {
440
+ when (v) {
441
+ is Double -> m.putDouble(k, v)
442
+ is String -> m.putString(k, v)
443
+ }
444
+ }
445
+ arr.pushMap(m)
446
+ }
447
+ val body = com.facebook.react.bridge.Arguments.createMap()
448
+ body.putArray("files", arr)
449
+ // 복사에 실패한 파일 (추가 필드 — files 는 예전과 같음)
450
+ val failedArr = com.facebook.react.bridge.Arguments.createArray()
451
+ for (f in failed) {
452
+ val m = com.facebook.react.bridge.Arguments.createMap()
453
+ m.putString("name", f["name"]?.toString() ?: "")
454
+ m.putString("error", f["error"]?.toString() ?: "")
455
+ failedArr.pushMap(m)
456
+ }
457
+ body.putArray("failed", failedArr)
458
+ emitGalleryEvent("onFilesSelected", body)
459
+ settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
460
+ }
341
461
  bottomSheet.setOnImagesSelectedListener { uris ->
462
+ lastVideoEditStates = HashMap(bottomSheet.videoEditStates)
342
463
  val result = WritableNativeArray()
343
464
  for (uri in uris) {
344
465
  result.pushString(uri.toString())
345
466
  }
346
- handleSelectedImages(result, pickImagesPromise)
347
- pickImagesPromise = null
467
+ settle { handleSelectedImages(result, it) }
348
468
  }
349
469
  bottomSheet.setOnCancelledListener {
350
- pickImagesPromise?.reject(E_PICKER_CANCELLED, "User cancelled image picker")
351
- pickImagesPromise = null
470
+ settle { it.reject(E_PICKER_CANCELLED, "User cancelled image picker") }
352
471
  }
353
472
 
354
473
  val existingFragment = fragmentActivity.supportFragmentManager.findFragmentByTag("ImagePickerBottomSheet")
@@ -361,13 +480,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
361
480
  .commitAllowingStateLoss()
362
481
 
363
482
  } catch (e: Exception) {
364
- pickImagesPromise?.reject(E_FAILED_TO_PICK, "Failed to open image picker: ${e.message}", e)
365
- pickImagesPromise = null
483
+ settle { it.reject(E_FAILED_TO_PICK, "Failed to open image picker: ${e.message}", e) }
366
484
  }
367
485
  }
368
486
  }
369
487
 
370
- if (ContextCompat.checkSelfPermission(fragmentActivity, permission) == PackageManager.PERMISSION_GRANTED) {
488
+ if (mediaPermissions.all { ContextCompat.checkSelfPermission(fragmentActivity, it) == PackageManager.PERMISSION_GRANTED }) {
371
489
  showSheet()
372
490
  } else {
373
491
  val permissionAwareActivity = fragmentActivity as? PermissionAwareActivity
@@ -382,28 +500,25 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
382
500
  showSheet()
383
501
  return
384
502
  }
385
- arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
503
+ mediaPermissions + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
386
504
  } else {
387
- arrayOf(permission)
505
+ mediaPermissions
388
506
  }
389
507
 
390
508
  permissionAwareActivity.requestPermissions(permissionsToRequest, 100, object : PermissionListener {
391
509
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Boolean {
392
510
  if (requestCode == 100 && grantResults != null) {
393
511
  val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
394
- grantResults.isNotEmpty() && (
395
- grantResults[0] == PackageManager.PERMISSION_GRANTED ||
396
- (grantResults.size > 1 && grantResults[1] == PackageManager.PERMISSION_GRANTED)
397
- )
512
+ // 14+: 전체 허용이든 "일부 선택"이든 하나라도 허용이면 진행
513
+ grantResults.any { it == PackageManager.PERMISSION_GRANTED }
398
514
  } else {
399
- grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
515
+ grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
400
516
  }
401
517
 
402
518
  if (isGranted) {
403
519
  showSheet()
404
520
  } else {
405
- pickImagesPromise?.reject("E_PERMISSION_DENIED", "Permission denied")
406
- pickImagesPromise = null
521
+ settle { it.reject("E_PERMISSION_DENIED", "Permission denied") }
407
522
  }
408
523
  }
409
524
  return true
@@ -536,6 +651,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
536
651
  putStringArrayList("placeholderImages", placeholders)
537
652
  }
538
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
+ }
539
670
  // 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
540
671
  // 네이티브 쪽 로케일 리소스(values-ko/ja/zh, 그 외 en)가 자동 적용됩니다.
541
672
  putBoolean("showActionButtons", !options.hasKey("showActionButtons") || options.getBoolean("showActionButtons"))
@@ -697,6 +828,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
697
828
  }
698
829
  }
699
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
+
700
839
  override fun closeGallery(promise: Promise) {
701
840
  val activity = getCurrentActivity()
702
841
  if (activity == null) {
@@ -745,9 +884,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
745
884
  return
746
885
  }
747
886
 
887
+ val snap = sendSnapshot()
748
888
  moduleScope.launch {
749
889
  try {
750
- val results = processImages(uris)
890
+ val results = processImages(uris, snap)
751
891
  if (results.isNotEmpty()) {
752
892
  promise.resolve(results[0])
753
893
  } else {
@@ -791,7 +931,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
791
931
  // Process images asynchronously
792
932
  val eventMap = com.facebook.react.bridge.WritableNativeMap()
793
933
  eventMap.putInt("selectedCount", uris.size)
794
- eventMap.putInt("maxSelection", 10)
934
+ eventMap.putInt("maxSelection", lastMaxSelection)
795
935
  try {
796
936
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
797
937
  .emit("onSelectionChange", eventMap)
@@ -799,25 +939,183 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
799
939
  // Ignore
800
940
  }
801
941
 
942
+ val snap = sendSnapshot()
802
943
  moduleScope.launch {
803
944
  try {
804
- val results = processImages(uris)
945
+ val results = processImages(uris, snap)
805
946
  val array = com.facebook.react.bridge.Arguments.createArray()
806
947
  results.forEach { array.pushMap(it) }
807
948
  actualPromise.resolve(array)
949
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
950
+ actualPromise.reject("CANCELLED", "Video export cancelled")
808
951
  } catch (e: Exception) {
809
952
  actualPromise.reject(E_FAILED_TO_PICK, "Failed to process images: ${e.message}", e)
810
953
  }
811
954
  }
812
955
  }
813
956
 
814
- 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()
815
968
  val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
816
969
  val contentResolver = reactApplicationContext.contentResolver
817
970
  val totalCount = uris.size
818
971
  var currentIndex = 0
819
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 {
820
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
+ }
821
1119
  try {
822
1120
  // 1. Get EXIF Orientation
823
1121
  var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
@@ -948,6 +1246,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
948
1246
  val eventMap = WritableNativeMap().apply {
949
1247
  putInt("total", totalCount)
950
1248
  putInt("index", currentIndex)
1249
+ putString("processingId", processingId)
951
1250
  putMap("image", createImageInfo())
952
1251
  }
953
1252
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@@ -963,6 +1262,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
963
1262
  continue
964
1263
  }
965
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
+ }
966
1273
 
967
1274
  results
968
1275
  }
@@ -1158,5 +1465,263 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1158
1465
  androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(reactApplicationContext)
1159
1466
  .unregisterReceiver(imageDeletedReceiver)
1160
1467
  }
1161
- }
1162
1468
 
1469
+ // ── 지도 뷰어 (위치 메시지 → 지도) ──
1470
+
1471
+ private var mapViewer: java.lang.ref.WeakReference<MapViewerDialogFragment>? = null
1472
+ /** updateMapViewerSourceRect 로 갱신되는 닫힘 위치 (화면 px) */
1473
+ @Volatile private var mapViewerSourceRect: android.graphics.Rect? = null
1474
+
1475
+ /** RN measureInWindow 값(dp, 창 기준) → 화면 px Rect */
1476
+ private fun mapRectFromDp(map: ReadableMap?, activity: Activity): android.graphics.Rect? {
1477
+ if (map == null || !map.hasKey("x") || !map.hasKey("y") || !map.hasKey("width") || !map.hasKey("height")) return null
1478
+ val w = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("width").toFloat())
1479
+ val h = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("height").toFloat())
1480
+ if (w <= 0f || h <= 0f) return null
1481
+ val loc = IntArray(2)
1482
+ try { activity.window?.decorView?.getLocationOnScreen(loc) } catch (_: Exception) {}
1483
+ val x = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("x").toFloat()) + loc[0]
1484
+ val y = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("y").toFloat()) + loc[1]
1485
+ return android.graphics.Rect(Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h))
1486
+ }
1487
+
1488
+ /** 말풍선 이미지 → 로컬 파일 경로. file:// 는 그대로, http(s) 는 Glide 캐시 파일. 실패·지연 시 null (이미지 없이 엶) */
1489
+ private fun mapSourceImagePath(uri: String?): String? {
1490
+ if (uri.isNullOrBlank()) return null
1491
+ if (uri.startsWith("/")) return uri.takeIf { File(it).exists() }
1492
+ if (uri.startsWith("file://")) return Uri.parse(uri).path?.takeIf { File(it).exists() }
1493
+ if (!uri.startsWith("http://") && !uri.startsWith("https://")) return null
1494
+ return try {
1495
+ com.bumptech.glide.Glide.with(reactApplicationContext.applicationContext)
1496
+ .downloadOnly().load(uri).submit()
1497
+ .get(1500, java.util.concurrent.TimeUnit.MILLISECONDS)
1498
+ ?.absolutePath
1499
+ } catch (e: Exception) {
1500
+ android.util.Log.w("RNAttachmentKit", "openMapViewer: source image not loaded (${e.javaClass.simpleName})")
1501
+ null
1502
+ }
1503
+ }
1504
+
1505
+ override fun openMapViewer(options: ReadableMap, promise: Promise) {
1506
+ val activity = getCurrentActivity() as? FragmentActivity
1507
+ if (activity == null) {
1508
+ promise.reject("NO_ACTIVITY", "Activity is not a FragmentActivity")
1509
+ return
1510
+ }
1511
+ if (!options.hasKey("latitude") || !options.hasKey("longitude")) {
1512
+ promise.reject("ERROR", "latitude and longitude are required")
1513
+ return
1514
+ }
1515
+ fun str(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getString(key) else null
1516
+ fun num(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
1517
+ val lat = options.getDouble("latitude")
1518
+ val lng = options.getDouble("longitude")
1519
+ val rect = mapRectFromDp(if (options.hasKey("sourceRect") && !options.isNull("sourceRect")) options.getMap("sourceRect") else null, activity)
1520
+ val imageUri = str("sourceImageUri")
1521
+ val themeColor = str("themeColor")?.let { try { android.graphics.Color.parseColor(it) } catch (_: Exception) { null } }
1522
+ val radiusPx = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP((num("sourceCornerRadius") ?: 12.0).toFloat())
1523
+ val showDirections = if (options.hasKey("showDirections") && !options.isNull("showDirections")) options.getBoolean("showDirections") else true
1524
+
1525
+ CoroutineScope(Dispatchers.IO).launch {
1526
+ val imagePath = if (rect != null) mapSourceImagePath(imageUri) else null
1527
+ withContext(Dispatchers.Main) {
1528
+ if (activity.isFinishing || activity.isDestroyed || activity.supportFragmentManager.isStateSaved) {
1529
+ promise.reject("ERROR", "Activity is not available")
1530
+ return@withContext
1531
+ }
1532
+ try {
1533
+ mapViewerSourceRect = rect
1534
+ val f = MapViewerDialogFragment.show(
1535
+ activity, lat, lng,
1536
+ title = str("title"),
1537
+ address = str("address"),
1538
+ markerType = str("markerType"),
1539
+ themeColor = themeColor,
1540
+ languageCode = str("languageCode") ?: "en",
1541
+ showDirections = showDirections,
1542
+ sourceRect = rect,
1543
+ sourceImagePath = imagePath,
1544
+ sourceCornerRadius = radiusPx,
1545
+ spanMeters = num("spanMeters") ?: 0.0,
1546
+ appearance = str("appearance"),
1547
+ addressLanguageCode = str("addressLanguageCode"),
1548
+ )
1549
+ if (rect != null) f.sourceRectProvider = { mapViewerSourceRect }
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
+ }
1558
+ f.onClosed = {
1559
+ if (!settled) { settled = true; promise.resolve(null) }
1560
+ mapViewerSourceRect = null
1561
+ mapViewer = null
1562
+ }
1563
+ mapViewer = java.lang.ref.WeakReference(f)
1564
+ } catch (e: Exception) {
1565
+ promise.reject("ERROR", e.message, e)
1566
+ }
1567
+ }
1568
+ }
1569
+ }
1570
+
1571
+ override fun updateMapViewerSourceRect(rect: ReadableMap, promise: Promise) {
1572
+ val activity = getCurrentActivity()
1573
+ if (mapViewer?.get() != null && activity != null) mapViewerSourceRect = mapRectFromDp(rect, activity)
1574
+ promise.resolve(null)
1575
+ }
1576
+
1577
+ /**
1578
+ * 위치 메시지 지도 썸네일을 이 기기에서 그림 (테마별 캐시). 결과 { uri, path, width, height(px), cached }
1579
+ * options: latitude, longitude, width, height (dp, 필수) / markerType, placeCategory, spanMeters, themeColor,
1580
+ * appearance ("light"/"dark"/"system"), myLatitude + myLongitude (둘 다 있을 때만)
1581
+ * aspect ("square" | "portrait" | "device"): 주면 width 만 필수, height 는 비율로 (square = 1:1,
1582
+ * portrait = 480:1040 보내던 이미지와 같은 9:19.5, device = 실제 화면 긴 변/짧은 변). 모르는 값이면 없는 것과 같음
1583
+ * maxWidth / maxHeight (px, 기본 512, 0 이하면 무시): 출력 WebP 가 이 안에 들어가게 비율대로 줄임
1584
+ * (k = min(density, maxWidth / widthDp, maxHeight / heightDp)) → 결과 width/height 는 출력 px
1585
+ */
1586
+ @ReactMethod
1587
+ override fun renderLocationThumbnail(options: ReadableMap, promise: Promise) {
1588
+ fun str(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getString(key) else null
1589
+ fun num(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
1590
+ val lat = num("latitude")
1591
+ val lng = num("longitude")
1592
+ val wDp = num("width")
1593
+ val activity = getCurrentActivity()
1594
+ val aspectRatio: Double? = when (str("aspect")?.lowercase()) {
1595
+ "square" -> 1.0
1596
+ "portrait" -> 1040.0 / 480.0
1597
+ "device" -> {
1598
+ // 실제 화면 크기 (세로 기준: 긴 변 / 짧은 변 → h > w)
1599
+ val dm = (activity ?: reactApplicationContext).resources.displayMetrics
1600
+ var sw = dm.widthPixels; var sh = dm.heightPixels
1601
+ if (activity != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
1602
+ try {
1603
+ val b = activity.windowManager.currentWindowMetrics.bounds
1604
+ if (b.width() > 0 && b.height() > 0) { sw = b.width(); sh = b.height() }
1605
+ } catch (_: Exception) {}
1606
+ }
1607
+ val lo = minOf(sw, sh); val hi = maxOf(sw, sh)
1608
+ if (lo > 0) hi.toDouble() / lo else 19.5 / 9.0
1609
+ }
1610
+ else -> null
1611
+ }
1612
+ val hDp = if (aspectRatio != null) wDp?.let { it * aspectRatio } else num("height")
1613
+ if (lat == null || lng == null || wDp == null || hDp == null || wDp <= 0 || hDp <= 0 ||
1614
+ lat.isNaN() || lng.isNaN()) {
1615
+ promise.reject("E_INVALID_ARGS", "latitude, longitude, width, height (> 0) are required")
1616
+ return
1617
+ }
1618
+ val myLat = num("myLatitude")
1619
+ val myLng = num("myLongitude")
1620
+ if ((myLat == null) != (myLng == null)) {
1621
+ promise.reject("E_INVALID_ARGS", "myLatitude and myLongitude must be given together")
1622
+ return
1623
+ }
1624
+ if (activity == null) {
1625
+ promise.reject(E_NO_ACTIVITY, "Activity doesn't exist")
1626
+ return
1627
+ }
1628
+ val accent = str("themeColor")?.let { try { android.graphics.Color.parseColor(it) } catch (_: Exception) { null } }
1629
+ ?: android.graphics.Color.parseColor("#FF6B35")
1630
+ val dark = when (str("appearance")?.lowercase()) {
1631
+ "dark" -> true
1632
+ "light" -> false
1633
+ else -> (activity.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
1634
+ android.content.res.Configuration.UI_MODE_NIGHT_YES
1635
+ }
1636
+ val wPx = Math.round(com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(wDp.toFloat())).coerceAtLeast(1)
1637
+ val hPx = Math.round(com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(hDp.toFloat())).coerceAtLeast(1)
1638
+ val maxOutW = num("maxWidth")?.takeIf { it > 0 }?.let { Math.round(it).toInt() }
1639
+ val maxOutH = num("maxHeight")?.takeIf { it > 0 }?.let { Math.round(it).toInt() }
1640
+ val req = LocationThumbnailRenderer.Request(
1641
+ lat = lat, lng = lng, widthPx = wPx, heightPx = hPx,
1642
+ density = activity.resources.displayMetrics.density,
1643
+ dark = dark,
1644
+ markerType = str("markerType"),
1645
+ placeCategory = str("placeCategory"),
1646
+ spanMeters = num("spanMeters"),
1647
+ accent = accent,
1648
+ myLat = myLat, myLng = myLng,
1649
+ // 출력 최대 크기 (0 이하·없음 → 기본 512)
1650
+ // 결과 최대 크기 (px). 둘 다 없으면 512 × 512, 하나만 주면 다른 쪽은 제한 없음(0)
1651
+ maxOutWidthPx = maxOutW ?: if (maxOutH != null) 0 else LocationThumbnailRenderer.DEFAULT_MAX_OUT_PX,
1652
+ maxOutHeightPx = maxOutH ?: if (maxOutW != null) 0 else LocationThumbnailRenderer.DEFAULT_MAX_OUT_PX,
1653
+ )
1654
+ com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
1655
+ try {
1656
+ LocationThumbnailRenderer.render(activity, req) { r ->
1657
+ r.fold(
1658
+ onSuccess = { m ->
1659
+ val body = Arguments.createMap()
1660
+ body.putString("uri", m["uri"] as? String)
1661
+ body.putString("path", m["path"] as? String)
1662
+ body.putInt("width", (m["width"] as? Int) ?: wPx)
1663
+ body.putInt("height", (m["height"] as? Int) ?: hPx)
1664
+ body.putBoolean("cached", (m["cached"] as? Boolean) ?: false)
1665
+ promise.resolve(body)
1666
+ },
1667
+ onFailure = { e -> promise.reject("E_THUMBNAIL_FAILED", e.message ?: "Thumbnail render failed", e) }
1668
+ )
1669
+ }
1670
+ } catch (e: Exception) {
1671
+ promise.reject("E_THUMBNAIL_FAILED", e.message ?: "Thumbnail render failed", e)
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ /** 지도 썸네일 캐시 비우기 */
1677
+ @ReactMethod
1678
+ override fun clearLocationThumbnailCache(promise: Promise) {
1679
+ CoroutineScope(Dispatchers.IO).launch {
1680
+ try { LocationThumbnailRenderer.clearCache(reactApplicationContext) } catch (_: Exception) {}
1681
+ promise.resolve(null)
1682
+ }
1683
+ }
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
+
1702
+ // ── 역지오코딩 (기기 Geocoder, 디스크 캐시) ──
1703
+
1704
+ override fun reverseGeocode(options: ReadableMap, promise: Promise) {
1705
+ val lat = if (options.hasKey("latitude") && !options.isNull("latitude")) options.getDouble("latitude") else Double.NaN
1706
+ val lng = if (options.hasKey("longitude") && !options.isNull("longitude")) options.getDouble("longitude") else Double.NaN
1707
+ val lang = if (options.hasKey("languageCode") && !options.isNull("languageCode")) options.getString("languageCode") else null
1708
+ ReverseGeocoder.reverseGeocode(reactApplicationContext, lat, lng, lang) { r, e ->
1709
+ if (r != null) {
1710
+ val map = Arguments.createMap()
1711
+ r.address?.let { map.putString("address", it) } ?: map.putNull("address")
1712
+ r.shortAddress?.let { map.putString("shortAddress", it) } ?: map.putNull("shortAddress")
1713
+ map.putBoolean("cached", r.cached)
1714
+ promise.resolve(map)
1715
+ } else {
1716
+ promise.reject(e?.code ?: "E_GEOCODE_FAILED", e?.message ?: "Reverse geocoding failed")
1717
+ }
1718
+ }
1719
+ }
1720
+
1721
+ override fun clearReverseGeocodeCache(promise: Promise) {
1722
+ CoroutineScope(Dispatchers.IO).launch {
1723
+ try { ReverseGeocoder.clearCache(reactApplicationContext) } catch (_: Exception) {}
1724
+ promise.resolve(null)
1725
+ }
1726
+ }
1727
+ }