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
@@ -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")
@@ -262,31 +305,107 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
262
305
  }
263
306
 
264
307
  pickImagesPromise = promise
308
+ // 이번 호출의 promise 를 붙잡아 둠 — 리스너가 모듈 필드를 읽으면, 시트가 열린 채 openGallery 를 다시 불렀을 때
309
+ // 이전 시트가 치워지며 오는 onDismiss 가 "새" promise 를 거부해 버림. 시트마다 자기 promise 를 한 번만 끝냄
310
+ val thisPromise = promise
311
+ var settled = false
312
+ val settle: ((Promise) -> Unit) -> Unit = { block ->
313
+ if (!settled) {
314
+ settled = true
315
+ if (pickImagesPromise === thisPromise) pickImagesPromise = null
316
+ block(thisPromise)
317
+ }
318
+ }
319
+ // 지난 호출이 남긴 오래된 임시 파일(24시간 지난 것) 정리 — 백그라운드
320
+ moduleScope.launch(Dispatchers.IO) {
321
+ try { TempFileCleaner.cleanStale(reactApplicationContext) } catch (_: Exception) {}
322
+ }
265
323
 
266
324
  val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
267
325
  Manifest.permission.READ_MEDIA_IMAGES
268
326
  } else {
269
327
  Manifest.permission.READ_EXTERNAL_STORAGE
270
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
+ }
271
335
 
272
336
  val showSheet = {
273
337
  fragmentActivity.runOnUiThread {
274
338
  try {
275
339
  val bottomSheet = ImagePickerBottomSheet.newInstance(
276
340
  maxSelection, languageCode, enableEditor, profileMode, maxWidth, maxHeight, themeColor,
277
- forceMultiMode = forceMultiMode
341
+ forceMultiMode = forceMultiMode,
342
+ // 첨부 메뉴(갤러리 / 위치 / 파일). 기본 false — 켜지 않으면 기존과 같음
343
+ showAttachMenu = options.hasKey("showAttachMenu") && options.getBoolean("showAttachMenu"),
344
+ // (Android) 첨부 메뉴 바 아래 여백 (dp). 없으면 기본 12 / 8
345
+ attachMenuSinkIntoNav = if (options.hasKey("attachMenuSinkIntoNav") && !options.isNull("attachMenuSinkIntoNav"))
346
+ options.getDouble("attachMenuSinkIntoNav").toFloat() else com.rnattachmentkit.AttachMenuBarView.SINK_INTO_NAV_DP.toFloat(),
347
+ attachMenuMinBottomMargin = if (options.hasKey("attachMenuMinBottomMargin") && !options.isNull("attachMenuMinBottomMargin"))
348
+ options.getDouble("attachMenuMinBottomMargin").toFloat() else com.rnattachmentkit.AttachMenuBarView.BOTTOM_MARGIN_DP.toFloat(),
349
+ // 위치 보내기 때 지도 이미지 캡처 여부. 기본 true (기존 그대로). false 면 이미지 없이 위치 정보만
350
+ captureLocationImage = !(options.hasKey("captureLocationImage") && !options.isNull("captureLocationImage")) ||
351
+ options.getBoolean("captureLocationImage"),
352
+ // 동영상 (mediaType "video"/"all" 일 때 그리드에 동영상, 길이 제한)
353
+ mediaType = mediaType,
354
+ maxVideoDurationSec = videoOptions.maxDurationSec
278
355
  )
356
+ // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
357
+ bottomSheet.setOnLocationSelectedListener { loc ->
358
+ val body = com.facebook.react.bridge.Arguments.createMap()
359
+ for ((k, v) in loc) {
360
+ when (v) {
361
+ is Int -> body.putInt(k, v)
362
+ is Long -> body.putDouble(k, v.toDouble())
363
+ is Double -> body.putDouble(k, v)
364
+ is Float -> body.putDouble(k, v.toDouble())
365
+ is Boolean -> body.putBoolean(k, v)
366
+ is String -> body.putString(k, v)
367
+ }
368
+ }
369
+ emitGalleryEvent("onLocationSelected", body)
370
+ settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
371
+ }
372
+ // 파일 탭에서 보내기 → onFilesSelected 이벤트 { files: [{uri, path, name, size, mimeType}], failed: [{name, error}] }, openGallery 는 빈 배열
373
+ bottomSheet.setOnFilesResultListener { files, failed ->
374
+ val arr = com.facebook.react.bridge.Arguments.createArray()
375
+ for (file in files) {
376
+ val m = com.facebook.react.bridge.Arguments.createMap()
377
+ for ((k, v) in file) {
378
+ when (v) {
379
+ is Double -> m.putDouble(k, v)
380
+ is String -> m.putString(k, v)
381
+ }
382
+ }
383
+ arr.pushMap(m)
384
+ }
385
+ val body = com.facebook.react.bridge.Arguments.createMap()
386
+ body.putArray("files", arr)
387
+ // 복사에 실패한 파일 (추가 필드 — files 는 예전과 같음)
388
+ val failedArr = com.facebook.react.bridge.Arguments.createArray()
389
+ for (f in failed) {
390
+ val m = com.facebook.react.bridge.Arguments.createMap()
391
+ m.putString("name", f["name"]?.toString() ?: "")
392
+ m.putString("error", f["error"]?.toString() ?: "")
393
+ failedArr.pushMap(m)
394
+ }
395
+ body.putArray("failed", failedArr)
396
+ emitGalleryEvent("onFilesSelected", body)
397
+ settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
398
+ }
279
399
  bottomSheet.setOnImagesSelectedListener { uris ->
400
+ lastVideoEditStates = HashMap(bottomSheet.videoEditStates)
280
401
  val result = WritableNativeArray()
281
402
  for (uri in uris) {
282
403
  result.pushString(uri.toString())
283
404
  }
284
- handleSelectedImages(result, pickImagesPromise)
285
- pickImagesPromise = null
405
+ settle { handleSelectedImages(result, it) }
286
406
  }
287
407
  bottomSheet.setOnCancelledListener {
288
- pickImagesPromise?.reject(E_PICKER_CANCELLED, "User cancelled image picker")
289
- pickImagesPromise = null
408
+ settle { it.reject(E_PICKER_CANCELLED, "User cancelled image picker") }
290
409
  }
291
410
 
292
411
  val existingFragment = fragmentActivity.supportFragmentManager.findFragmentByTag("ImagePickerBottomSheet")
@@ -299,13 +418,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
299
418
  .commitAllowingStateLoss()
300
419
 
301
420
  } catch (e: Exception) {
302
- pickImagesPromise?.reject(E_FAILED_TO_PICK, "Failed to open image picker: ${e.message}", e)
303
- pickImagesPromise = null
421
+ settle { it.reject(E_FAILED_TO_PICK, "Failed to open image picker: ${e.message}", e) }
304
422
  }
305
423
  }
306
424
  }
307
425
 
308
- if (ContextCompat.checkSelfPermission(fragmentActivity, permission) == PackageManager.PERMISSION_GRANTED) {
426
+ if (mediaPermissions.all { ContextCompat.checkSelfPermission(fragmentActivity, it) == PackageManager.PERMISSION_GRANTED }) {
309
427
  showSheet()
310
428
  } else {
311
429
  val permissionAwareActivity = fragmentActivity as? PermissionAwareActivity
@@ -320,28 +438,25 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
320
438
  showSheet()
321
439
  return
322
440
  }
323
- arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
441
+ mediaPermissions + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
324
442
  } else {
325
- arrayOf(permission)
443
+ mediaPermissions
326
444
  }
327
445
 
328
446
  permissionAwareActivity.requestPermissions(permissionsToRequest, 100, object : PermissionListener {
329
447
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Boolean {
330
448
  if (requestCode == 100 && grantResults != null) {
331
449
  val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
332
- grantResults.isNotEmpty() && (
333
- grantResults[0] == PackageManager.PERMISSION_GRANTED ||
334
- (grantResults.size > 1 && grantResults[1] == PackageManager.PERMISSION_GRANTED)
335
- )
450
+ // 14+: 전체 허용이든 "일부 선택"이든 하나라도 허용이면 진행
451
+ grantResults.any { it == PackageManager.PERMISSION_GRANTED }
336
452
  } else {
337
- grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
453
+ grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
338
454
  }
339
455
 
340
456
  if (isGranted) {
341
457
  showSheet()
342
458
  } else {
343
- pickImagesPromise?.reject("E_PERMISSION_DENIED", "Permission denied")
344
- pickImagesPromise = null
459
+ settle { it.reject("E_PERMISSION_DENIED", "Permission denied") }
345
460
  }
346
461
  }
347
462
  return true
@@ -398,6 +513,15 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
398
513
  promise.resolve(null)
399
514
  }
400
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
+
401
525
  @ReactMethod
402
526
  override fun closeGallery(promise: Promise) {
403
527
  val currentActivity = currentActivity
@@ -536,6 +660,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
536
660
  putStringArrayList("placeholderImages", placeholders)
537
661
  }
538
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
+ }
539
679
  // 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
540
680
  // 네이티브 쪽 로케일 리소스(values-ko/ja/zh, 그 외 en)가 자동 적용됩니다.
541
681
  putBoolean("showActionButtons", !options.hasKey("showActionButtons") || options.getBoolean("showActionButtons"))
@@ -728,9 +868,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
728
868
  return
729
869
  }
730
870
 
871
+ val snap = sendSnapshot()
731
872
  moduleScope.launch {
732
873
  try {
733
- val results = processImages(uris)
874
+ val results = processImages(uris, snap)
734
875
  if (results.isNotEmpty()) {
735
876
  promise.resolve(results[0])
736
877
  } else {
@@ -774,7 +915,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
774
915
  // Process images asynchronously
775
916
  val eventMap = com.facebook.react.bridge.WritableNativeMap()
776
917
  eventMap.putInt("selectedCount", uris.size)
777
- eventMap.putInt("maxSelection", 10)
918
+ eventMap.putInt("maxSelection", lastMaxSelection)
778
919
  try {
779
920
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
780
921
  .emit("onSelectionChange", eventMap)
@@ -782,25 +923,183 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
782
923
  // Ignore
783
924
  }
784
925
 
926
+ val snap = sendSnapshot()
785
927
  moduleScope.launch {
786
928
  try {
787
- val results = processImages(uris)
929
+ val results = processImages(uris, snap)
788
930
  val array = com.facebook.react.bridge.Arguments.createArray()
789
931
  results.forEach { array.pushMap(it) }
790
932
  actualPromise.resolve(array)
933
+ } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
934
+ actualPromise.reject("CANCELLED", "Video export cancelled")
791
935
  } catch (e: Exception) {
792
936
  actualPromise.reject(E_FAILED_TO_PICK, "Failed to process images: ${e.message}", e)
793
937
  }
794
938
  }
795
939
  }
796
940
 
797
- 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()
798
952
  val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
799
953
  val contentResolver = reactApplicationContext.contentResolver
800
954
  val totalCount = uris.size
801
955
  var currentIndex = 0
802
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 {
803
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
+ }
804
1103
  try {
805
1104
  // 1. Get EXIF Orientation
806
1105
  var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
@@ -931,6 +1230,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
931
1230
  val eventMap = WritableNativeMap().apply {
932
1231
  putInt("total", totalCount)
933
1232
  putInt("index", currentIndex)
1233
+ putString("processingId", processingId)
934
1234
  putMap("image", createImageInfo())
935
1235
  }
936
1236
  reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@@ -946,6 +1246,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
946
1246
  continue
947
1247
  }
948
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
+ }
949
1257
 
950
1258
  results
951
1259
  }
@@ -1142,4 +1450,267 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
1142
1450
  .unregisterReceiver(imageDeletedReceiver)
1143
1451
  }
1144
1452
 
1453
+ // ── 지도 뷰어 (위치 메시지 → 지도) ──
1454
+
1455
+ private var mapViewer: java.lang.ref.WeakReference<MapViewerDialogFragment>? = null
1456
+ /** updateMapViewerSourceRect 로 갱신되는 닫힘 위치 (화면 px) */
1457
+ @Volatile private var mapViewerSourceRect: android.graphics.Rect? = null
1458
+
1459
+ /** RN measureInWindow 값(dp, 창 기준) → 화면 px Rect */
1460
+ private fun mapRectFromDp(map: ReadableMap?, activity: Activity): android.graphics.Rect? {
1461
+ if (map == null || !map.hasKey("x") || !map.hasKey("y") || !map.hasKey("width") || !map.hasKey("height")) return null
1462
+ val w = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("width").toFloat())
1463
+ val h = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("height").toFloat())
1464
+ if (w <= 0f || h <= 0f) return null
1465
+ val loc = IntArray(2)
1466
+ try { activity.window?.decorView?.getLocationOnScreen(loc) } catch (_: Exception) {}
1467
+ val x = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("x").toFloat()) + loc[0]
1468
+ val y = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(map.getDouble("y").toFloat()) + loc[1]
1469
+ return android.graphics.Rect(Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h))
1470
+ }
1471
+
1472
+ /** 말풍선 이미지 → 로컬 파일 경로. file:// 는 그대로, http(s) 는 Glide 캐시 파일. 실패·지연 시 null (이미지 없이 엶) */
1473
+ private fun mapSourceImagePath(uri: String?): String? {
1474
+ if (uri.isNullOrBlank()) return null
1475
+ if (uri.startsWith("/")) return uri.takeIf { File(it).exists() }
1476
+ if (uri.startsWith("file://")) return Uri.parse(uri).path?.takeIf { File(it).exists() }
1477
+ if (!uri.startsWith("http://") && !uri.startsWith("https://")) return null
1478
+ return try {
1479
+ com.bumptech.glide.Glide.with(reactApplicationContext.applicationContext)
1480
+ .downloadOnly().load(uri).submit()
1481
+ .get(1500, java.util.concurrent.TimeUnit.MILLISECONDS)
1482
+ ?.absolutePath
1483
+ } catch (e: Exception) {
1484
+ android.util.Log.w("RNAttachmentKit", "openMapViewer: source image not loaded (${e.javaClass.simpleName})")
1485
+ null
1486
+ }
1487
+ }
1488
+
1489
+ @ReactMethod
1490
+ fun openMapViewer(options: ReadableMap, promise: Promise) {
1491
+ val activity = getCurrentActivity() as? FragmentActivity
1492
+ if (activity == null) {
1493
+ promise.reject("NO_ACTIVITY", "Activity is not a FragmentActivity")
1494
+ return
1495
+ }
1496
+ if (!options.hasKey("latitude") || !options.hasKey("longitude")) {
1497
+ promise.reject("ERROR", "latitude and longitude are required")
1498
+ return
1499
+ }
1500
+ fun str(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getString(key) else null
1501
+ fun num(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
1502
+ val lat = options.getDouble("latitude")
1503
+ val lng = options.getDouble("longitude")
1504
+ val rect = mapRectFromDp(if (options.hasKey("sourceRect") && !options.isNull("sourceRect")) options.getMap("sourceRect") else null, activity)
1505
+ val imageUri = str("sourceImageUri")
1506
+ val themeColor = str("themeColor")?.let { try { android.graphics.Color.parseColor(it) } catch (_: Exception) { null } }
1507
+ val radiusPx = com.facebook.react.uimanager.PixelUtil.toPixelFromDIP((num("sourceCornerRadius") ?: 12.0).toFloat())
1508
+ val showDirections = if (options.hasKey("showDirections") && !options.isNull("showDirections")) options.getBoolean("showDirections") else true
1509
+
1510
+ CoroutineScope(Dispatchers.IO).launch {
1511
+ val imagePath = if (rect != null) mapSourceImagePath(imageUri) else null
1512
+ withContext(Dispatchers.Main) {
1513
+ if (activity.isFinishing || activity.isDestroyed || activity.supportFragmentManager.isStateSaved) {
1514
+ promise.reject("ERROR", "Activity is not available")
1515
+ return@withContext
1516
+ }
1517
+ try {
1518
+ mapViewerSourceRect = rect
1519
+ val f = MapViewerDialogFragment.show(
1520
+ activity, lat, lng,
1521
+ title = str("title"),
1522
+ address = str("address"),
1523
+ markerType = str("markerType"),
1524
+ themeColor = themeColor,
1525
+ languageCode = str("languageCode") ?: "en",
1526
+ showDirections = showDirections,
1527
+ sourceRect = rect,
1528
+ sourceImagePath = imagePath,
1529
+ sourceCornerRadius = radiusPx,
1530
+ spanMeters = num("spanMeters") ?: 0.0,
1531
+ appearance = str("appearance"),
1532
+ addressLanguageCode = str("addressLanguageCode"),
1533
+ )
1534
+ if (rect != null) f.sourceRectProvider = { mapViewerSourceRect }
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
+ }
1543
+ f.onClosed = {
1544
+ if (!settled) { settled = true; promise.resolve(null) }
1545
+ mapViewerSourceRect = null
1546
+ mapViewer = null
1547
+ }
1548
+ mapViewer = java.lang.ref.WeakReference(f)
1549
+ } catch (e: Exception) {
1550
+ promise.reject("ERROR", e.message, e)
1551
+ }
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ @ReactMethod
1557
+ fun updateMapViewerSourceRect(rect: ReadableMap, promise: Promise) {
1558
+ val activity = getCurrentActivity()
1559
+ if (mapViewer?.get() != null && activity != null) mapViewerSourceRect = mapRectFromDp(rect, activity)
1560
+ promise.resolve(null)
1561
+ }
1562
+
1563
+ /**
1564
+ * 위치 메시지 지도 썸네일을 이 기기에서 그림 (테마별 캐시). 결과 { uri, path, width, height(px), cached }
1565
+ * options: latitude, longitude, width, height (dp, 필수) / markerType, placeCategory, spanMeters, themeColor,
1566
+ * appearance ("light"/"dark"/"system"), myLatitude + myLongitude (둘 다 있을 때만)
1567
+ * aspect ("square" | "portrait" | "device"): 주면 width 만 필수, height 는 비율로 (square = 1:1,
1568
+ * portrait = 480:1040 보내던 이미지와 같은 9:19.5, device = 실제 화면 긴 변/짧은 변). 모르는 값이면 없는 것과 같음
1569
+ * maxWidth / maxHeight (px, 기본 512, 0 이하면 무시): 출력 WebP 가 이 안에 들어가게 비율대로 줄임
1570
+ * (k = min(density, maxWidth / widthDp, maxHeight / heightDp)) → 결과 width/height 는 출력 px
1571
+ */
1572
+ @ReactMethod
1573
+ fun renderLocationThumbnail(options: ReadableMap, promise: Promise) {
1574
+ fun str(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getString(key) else null
1575
+ fun num(key: String) = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
1576
+ val lat = num("latitude")
1577
+ val lng = num("longitude")
1578
+ val wDp = num("width")
1579
+ val activity = getCurrentActivity()
1580
+ val aspectRatio: Double? = when (str("aspect")?.lowercase()) {
1581
+ "square" -> 1.0
1582
+ "portrait" -> 1040.0 / 480.0
1583
+ "device" -> {
1584
+ // 실제 화면 크기 (세로 기준: 긴 변 / 짧은 변 → h > w)
1585
+ val dm = (activity ?: reactApplicationContext).resources.displayMetrics
1586
+ var sw = dm.widthPixels; var sh = dm.heightPixels
1587
+ if (activity != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
1588
+ try {
1589
+ val b = activity.windowManager.currentWindowMetrics.bounds
1590
+ if (b.width() > 0 && b.height() > 0) { sw = b.width(); sh = b.height() }
1591
+ } catch (_: Exception) {}
1592
+ }
1593
+ val lo = minOf(sw, sh); val hi = maxOf(sw, sh)
1594
+ if (lo > 0) hi.toDouble() / lo else 19.5 / 9.0
1595
+ }
1596
+ else -> null
1597
+ }
1598
+ val hDp = if (aspectRatio != null) wDp?.let { it * aspectRatio } else num("height")
1599
+ if (lat == null || lng == null || wDp == null || hDp == null || wDp <= 0 || hDp <= 0 ||
1600
+ lat.isNaN() || lng.isNaN()) {
1601
+ promise.reject("E_INVALID_ARGS", "latitude, longitude, width, height (> 0) are required")
1602
+ return
1603
+ }
1604
+ val myLat = num("myLatitude")
1605
+ val myLng = num("myLongitude")
1606
+ if ((myLat == null) != (myLng == null)) {
1607
+ promise.reject("E_INVALID_ARGS", "myLatitude and myLongitude must be given together")
1608
+ return
1609
+ }
1610
+ if (activity == null) {
1611
+ promise.reject(E_NO_ACTIVITY, "Activity doesn't exist")
1612
+ return
1613
+ }
1614
+ val accent = str("themeColor")?.let { try { android.graphics.Color.parseColor(it) } catch (_: Exception) { null } }
1615
+ ?: android.graphics.Color.parseColor("#FF6B35")
1616
+ val dark = when (str("appearance")?.lowercase()) {
1617
+ "dark" -> true
1618
+ "light" -> false
1619
+ else -> (activity.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
1620
+ android.content.res.Configuration.UI_MODE_NIGHT_YES
1621
+ }
1622
+ val wPx = Math.round(com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(wDp.toFloat())).coerceAtLeast(1)
1623
+ val hPx = Math.round(com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(hDp.toFloat())).coerceAtLeast(1)
1624
+ val maxOutW = num("maxWidth")?.takeIf { it > 0 }?.let { Math.round(it).toInt() }
1625
+ val maxOutH = num("maxHeight")?.takeIf { it > 0 }?.let { Math.round(it).toInt() }
1626
+ val req = LocationThumbnailRenderer.Request(
1627
+ lat = lat, lng = lng, widthPx = wPx, heightPx = hPx,
1628
+ density = activity.resources.displayMetrics.density,
1629
+ dark = dark,
1630
+ markerType = str("markerType"),
1631
+ placeCategory = str("placeCategory"),
1632
+ spanMeters = num("spanMeters"),
1633
+ accent = accent,
1634
+ myLat = myLat, myLng = myLng,
1635
+ // 출력 최대 크기 (0 이하·없음 → 기본 512)
1636
+ // 결과 최대 크기 (px). 둘 다 없으면 512 × 512, 하나만 주면 다른 쪽은 제한 없음(0)
1637
+ maxOutWidthPx = maxOutW ?: if (maxOutH != null) 0 else LocationThumbnailRenderer.DEFAULT_MAX_OUT_PX,
1638
+ maxOutHeightPx = maxOutH ?: if (maxOutW != null) 0 else LocationThumbnailRenderer.DEFAULT_MAX_OUT_PX,
1639
+ )
1640
+ com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
1641
+ try {
1642
+ LocationThumbnailRenderer.render(activity, req) { r ->
1643
+ r.fold(
1644
+ onSuccess = { m ->
1645
+ val body = Arguments.createMap()
1646
+ body.putString("uri", m["uri"] as? String)
1647
+ body.putString("path", m["path"] as? String)
1648
+ body.putInt("width", (m["width"] as? Int) ?: wPx)
1649
+ body.putInt("height", (m["height"] as? Int) ?: hPx)
1650
+ body.putBoolean("cached", (m["cached"] as? Boolean) ?: false)
1651
+ promise.resolve(body)
1652
+ },
1653
+ onFailure = { e -> promise.reject("E_THUMBNAIL_FAILED", e.message ?: "Thumbnail render failed", e) }
1654
+ )
1655
+ }
1656
+ } catch (e: Exception) {
1657
+ promise.reject("E_THUMBNAIL_FAILED", e.message ?: "Thumbnail render failed", e)
1658
+ }
1659
+ }
1660
+ }
1661
+
1662
+ /** 지도 썸네일 캐시 비우기 */
1663
+ @ReactMethod
1664
+ fun clearLocationThumbnailCache(promise: Promise) {
1665
+ CoroutineScope(Dispatchers.IO).launch {
1666
+ try { LocationThumbnailRenderer.clearCache(reactApplicationContext) } catch (_: Exception) {}
1667
+ promise.resolve(null)
1668
+ }
1669
+ }
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
+
1689
+ // ── 역지오코딩 (기기 Geocoder, 디스크 캐시) ──
1690
+
1691
+ @ReactMethod
1692
+ fun reverseGeocode(options: ReadableMap, promise: Promise) {
1693
+ val lat = if (options.hasKey("latitude") && !options.isNull("latitude")) options.getDouble("latitude") else Double.NaN
1694
+ val lng = if (options.hasKey("longitude") && !options.isNull("longitude")) options.getDouble("longitude") else Double.NaN
1695
+ val lang = if (options.hasKey("languageCode") && !options.isNull("languageCode")) options.getString("languageCode") else null
1696
+ ReverseGeocoder.reverseGeocode(reactApplicationContext, lat, lng, lang) { r, e ->
1697
+ if (r != null) {
1698
+ val map = Arguments.createMap()
1699
+ r.address?.let { map.putString("address", it) } ?: map.putNull("address")
1700
+ r.shortAddress?.let { map.putString("shortAddress", it) } ?: map.putNull("shortAddress")
1701
+ map.putBoolean("cached", r.cached)
1702
+ promise.resolve(map)
1703
+ } else {
1704
+ promise.reject(e?.code ?: "E_GEOCODE_FAILED", e?.message ?: "Reverse geocoding failed")
1705
+ }
1706
+ }
1707
+ }
1708
+
1709
+ @ReactMethod
1710
+ fun clearReverseGeocodeCache(promise: Promise) {
1711
+ CoroutineScope(Dispatchers.IO).launch {
1712
+ try { ReverseGeocoder.clearCache(reactApplicationContext) } catch (_: Exception) {}
1713
+ promise.resolve(null)
1714
+ }
1715
+ }
1145
1716
  }