react-native-attachment-kit 1.0.17 → 1.1.0

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 (30) hide show
  1. package/CHANGELOG.md +472 -22
  2. package/android/build.gradle +8 -3
  3. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  4. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +60 -341
  5. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +60 -341
  6. package/ios/RNAttachmentKit.swift +66 -771
  7. package/ios/RNAttachmentKit.xcframework/Info.plist +5 -5
  8. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +6853 -669
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +108 -0
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +108 -0
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6853 -669
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +108 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +108 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6853 -669
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +108 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +108 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +22 -22
  29. package/package.json +1 -1
  30. package/src/types.ts +5 -0
@@ -97,7 +97,10 @@ if (project != rootProject) {
97
97
  }
98
98
 
99
99
  dependencies {
100
- implementation files('src/main/libs/rnattachmentkit-release.aar')
100
+ // 1.1.0: aar 를 `api` 로 — 앱의 네이티브 코드도 키트 공개 클래스(AttachmentProcessor·ImagePickerBottomSheet·
101
+ // ImageViewerOverlayView·VideoExporter …)를 바로 쓸 수 있게 (전엔 앱이 같은 aar 를 compileOnly 로 따로 잡아야 했음).
102
+ // 클래스 이름은 aar 의 proguard-rules.pro(-keep class com.rnattachmentkit.**)가 그대로 둠
103
+ api files('src/main/libs/rnattachmentkit-release.aar')
101
104
  compileOnly "com.facebook.react:react-android:+"
102
105
 
103
106
  implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.0"
@@ -107,7 +110,8 @@ if (project != rootProject) {
107
110
  implementation "androidx.fragment:fragment-ktx:1.6.2"
108
111
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
109
112
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
110
- implementation "com.google.android.material:material:1.11.0"
113
+ // 공개 클래스의 부모 타입(BottomSheetDialogFragment 등)이라 앱 컴파일 경로에도 보여야 함
114
+ api "com.google.android.material:material:1.11.0"
111
115
  implementation "androidx.recyclerview:recyclerview:1.3.2"
112
116
  implementation "androidx.cardview:cardview:1.0.0"
113
117
  implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0"
@@ -132,7 +136,8 @@ if (project != rootProject) {
132
136
  // 이미지 뷰어 동영상: HLS(.m3u8) 스트리밍 재생 (iOS AVPlayer 와 맞춤)
133
137
  implementation "androidx.media3:media3-exoplayer-hls:${media3_version}"
134
138
  implementation "androidx.media3:media3-ui:${media3_version}"
135
- implementation "androidx.media3:media3-transformer:${media3_version}"
139
+ // VideoExporter 공개 API 가 쓰는 타입 — 앱 컴파일 경로에도 보여야 함
140
+ api "androidx.media3:media3-transformer:${media3_version}"
136
141
  implementation "androidx.media3:media3-effect:${media3_version}"
137
142
 
138
143
  // CameraX dependencies — 1.4.0+ 부터 libimage_processing_util_jni.so 가 16KB 정렬 (AAR 쪽과 같은 버전)
@@ -47,8 +47,6 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
47
47
  private var lastMaxSelection: Int = 10
48
48
  private var lastThemeColorInt: Int? = null
49
49
  private var lastLanguageCode: String = "en"
50
- @Volatile private var currentVideoExporter: com.rnattachmentkit.VideoExporter? = null
51
- @Volatile private var videoCancelled = false
52
50
  /** 진행 중인 전송 처리 (processingId → 작업). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음 */
53
51
  private val activeProcessingJobs = java.util.concurrent.ConcurrentHashMap<String, com.rnattachmentkit.ProcessingJob>()
54
52
  /** 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때) */
@@ -699,6 +697,8 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
699
697
  // 네이티브 쪽 로케일 리소스(values-ko/ja/zh, 그 외 en)가 자동 적용됩니다.
700
698
  putBoolean("showActionButtons", !options.hasKey("showActionButtons") || options.getBoolean("showActionButtons"))
701
699
  putBoolean("showDeleteButton", !options.hasKey("showDeleteButton") || options.getBoolean("showDeleteButton"))
700
+ // 처음에 상단바·하단바·동영상 조작 UI 를 숨긴 채로 엶 (탭하면 보임)
701
+ putBoolean("startWithUiHidden", options.hasKey("startWithUiHidden") && options.getBoolean("startWithUiHidden"))
702
702
  for (key in listOf(
703
703
  "saveAllText", "saveOneText", "shareAllText", "shareOneText", "actionCancelText",
704
704
  "deleteConfirmTitle", "deleteConfirmMessage", "deleteConfirmGroupMessage", "deleteButtonText"
@@ -983,374 +983,93 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
983
983
  }
984
984
  }
985
985
 
986
+ /** 고른 뒤 처리 — com.rnattachmentkit.AttachmentProcessor (앱 네이티브 화면과 같은 코드). 여기선 RN 이벤트·HUD 만 */
986
987
  private suspend fun processImages(uris: List<Uri>, snap: SendSnapshot): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
987
- // 보낸 순간의 설정 (모듈 필드와 같은 이름으로 가림 — 아래 코드는 그대로)
988
- val maxWidth = snap.maxWidth
989
- val maxHeight = snap.maxHeight
990
- val outputFormat = snap.outputFormat
991
- val videoOptions = snap.videoOptions
992
- val lastVideoEditStates = snap.states
993
- val showVideoHUD = snap.showHUD
994
- val lastThemeColorInt = snap.themeColor
995
- val lastLanguageCode = snap.language
996
- val thumbnailOptions = snap.thumbnail
997
988
  val processingId = java.util.UUID.randomUUID().toString()
998
- val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
999
- val contentResolver = reactApplicationContext.contentResolver
1000
989
  val totalCount = uris.size
1001
- var currentIndex = 0
1002
-
1003
- // 동영상이 섞여 있으면 진행 HUD 를 띄우고, 동영상은 VideoExporter 로 (사진 처리와 같은 루프)
1004
- val isVideoUri: (Uri) -> Boolean = { u ->
1005
- u.toString().contains("/video/") || (try { contentResolver.getType(u)?.startsWith("video/") == true } catch (_: Exception) { false })
1006
- }
1007
- val hasVideo = uris.any(isVideoUri)
1008
- videoCancelled = false
990
+ val options = com.rnattachmentkit.AttachmentProcessor.Options(
991
+ maxWidth = snap.maxWidth, maxHeight = snap.maxHeight, outputFormat = snap.outputFormat,
992
+ thumbnail = snap.thumbnail, videoOptions = snap.videoOptions,
993
+ )
994
+ val hasVideo = uris.any { com.rnattachmentkit.AttachmentProcessor.isVideoUri(reactApplicationContext, it) }
995
+ // 앱의 cancelVideoProcessing(index) → job.cancel, HUD 의 취소 → job.abort (전체 취소)
1009
996
  val job = com.rnattachmentkit.ProcessingJob()
1010
997
  activeProcessingJobs[processingId] = job
1011
998
  lastProcessingId = processingId
1012
- // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1013
- try {
1014
- val items = Arguments.createArray()
1015
- uris.forEachIndexed { i, u ->
1016
- val it0 = if (isVideoUri(u)) {
1017
- val st = lastVideoEditStates[u.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1018
- com.rnattachmentkit.MediaProcessing.videoItem(reactApplicationContext, i, u, st, videoOptions)
1019
- } else {
1020
- com.rnattachmentkit.MediaProcessing.imageItem(reactApplicationContext, i, u, maxWidth, maxHeight)
1021
- }
1022
- items.pushMap(Arguments.createMap().apply {
1023
- putInt("index", it0.index)
1024
- putString("mediaType", if (it0.isVideo) "video" else "image")
1025
- putString("originalUri", it0.originalUri)
1026
- putInt("width", it0.width)
1027
- putInt("height", it0.height)
1028
- it0.durationSec?.let { d -> putDouble("duration", d) }
1029
- it0.thumbnailUri?.let { t ->
1030
- putString("thumbnailUri", t)
1031
- putInt("thumbnailWidth", it0.thumbnailWidth)
1032
- putInt("thumbnailHeight", it0.thumbnailHeight)
1033
- }
1034
- })
1035
- }
1036
- emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1037
- putString("processingId", processingId)
1038
- putInt("total", totalCount)
1039
- putArray("items", items)
1040
- })
1041
- } catch (e: Exception) {
1042
- android.util.Log.w("RNAttachmentKit", "onProcessingStart failed: ${e.message}")
1043
- }
1044
999
  var hud: com.rnattachmentkit.VideoExportHUD? = null
1045
- if (hasVideo && showVideoHUD) {
1000
+ if (hasVideo && snap.showHUD) {
1046
1001
  withContext(Dispatchers.Main) {
1047
1002
  val act = getCurrentActivity() ?: return@withContext
1048
- hud = com.rnattachmentkit.VideoExportHUD(act, lastThemeColorInt, lastLanguageCode).also { h ->
1049
- h.onCancel = {
1050
- videoCancelled = true
1051
- currentVideoExporter?.cancel()
1052
- }
1003
+ hud = com.rnattachmentkit.VideoExportHUD(act, snap.themeColor, snap.language).also { h ->
1004
+ h.onCancel = { job.abort() }
1053
1005
  h.show()
1054
1006
  }
1055
1007
  }
1056
1008
  }
1057
- val videoFiles = mutableListOf<File>()
1058
-
1009
+ val main = android.os.Handler(android.os.Looper.getMainLooper())
1010
+ val emitter = { reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) }
1059
1011
  try {
1060
- for (uri in uris) {
1061
- if (videoCancelled) throw com.rnattachmentkit.VideoExporter.CancelledException()
1062
- if (isVideoUri(uri)) {
1063
- val idx = currentIndex
1064
- val exporter = com.rnattachmentkit.VideoExporter(reactApplicationContext)
1065
- currentVideoExporter = exporter
1066
- val cancelledMap = {
1067
- WritableNativeMap().apply {
1068
- putString("originalUri", uri.toString())
1069
- putString("mediaType", "video")
1070
- putString("error", "CANCELLED")
1071
- putBoolean("cancelled", true)
1072
- }
1073
- }
1074
- val resultMap = try {
1075
- // 앱이 이미 취소한 동영상은 건너뜀
1076
- if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1077
- job.begin(idx, exporter)
1078
- val state = lastVideoEditStates[uri.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1079
- val throttle = com.rnattachmentkit.ProgressThrottle()
1080
- val onProgress: (Float) -> Unit = { p ->
1081
- hud?.let { h -> android.os.Handler(android.os.Looper.getMainLooper()).post { h.update(p, idx, totalCount) } }
1082
- if (throttle.shouldSend(p)) emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1083
- putInt("index", idx); putInt("total", totalCount); putDouble("progress", p.toDouble())
1084
- putString("processingId", processingId)
1085
- })
1086
- }
1087
- // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1088
- // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1089
- val stop = { videoCancelled || job.isCancelled(idx) }
1090
- val res = com.rnattachmentkit.VideoPrepareManager.takeReady(uri, state, onProgress, stop)
1091
- ?: run {
1092
- if (stop()) throw com.rnattachmentkit.VideoExporter.CancelledException()
1093
- exporter.export(uri, state, videoOptions, reactApplicationContext.cacheDir, "turbo_video_${System.currentTimeMillis()}_$idx", onProgress)
1094
- }
1095
- withContext(Dispatchers.Main) { hud?.update(1f, idx, totalCount) }
1096
- // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1097
- if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1098
- videoFiles += res.file
1099
- res.thumbnailFile?.let { videoFiles += it }
1100
- WritableNativeMap().apply {
1101
- putString("uri", Uri.fromFile(res.file).toString())
1102
- putString("originalUri", uri.toString())
1103
- putString("mediaType", "video")
1104
- putString("type", "video/mp4")
1105
- putInt("width", res.width)
1106
- putInt("height", res.height)
1107
- putDouble("duration", res.durationMs / 1000.0)
1108
- putDouble("fileSize", res.fileSize.toDouble())
1109
- putString("fileName", res.file.name)
1110
- putString("fileExtension", "mp4")
1111
- putString("videoCodec", res.codec)
1112
- putInt("videoBitrate", res.bitrate)
1113
- putBoolean("edited", res.edited)
1114
- if (res.thumbnailFile != null) {
1115
- putString("thumbnailUri", Uri.fromFile(res.thumbnailFile).toString())
1116
- putInt("thumbnailWidth", res.thumbnailWidth)
1117
- putInt("thumbnailHeight", res.thumbnailHeight)
1118
- }
1119
- }
1120
- } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1121
- // 앱이 이 동영상만 취소 → 나머지는 계속. HUD 취소면 전체 취소
1122
- if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else throw e
1123
- } catch (e: Exception) {
1124
- // 취소하면서 변환기가 다른 예외로 끝날 수도 있음 → 취소로 처리
1125
- if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else {
1126
- android.util.Log.w("RNAttachmentKit", "video export failed: ${e.message}", e)
1127
- WritableNativeMap().apply {
1128
- putString("originalUri", uri.toString())
1129
- putString("mediaType", "video")
1130
- putString("error", e.message ?: "export failed")
1012
+ com.rnattachmentkit.AttachmentProcessor.process(reactApplicationContext, uris, snap.states, options, job,
1013
+ object : com.rnattachmentkit.AttachmentProcessor.Listener {
1014
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1015
+ override fun onStart(items: List<com.rnattachmentkit.MediaProcessing.StartItem>) {
1016
+ val arr = Arguments.createArray()
1017
+ items.forEach { it0 ->
1018
+ arr.pushMap(Arguments.createMap().apply {
1019
+ putInt("index", it0.index)
1020
+ putString("mediaType", if (it0.isVideo) "video" else "image")
1021
+ putString("originalUri", it0.originalUri)
1022
+ putInt("width", it0.width)
1023
+ putInt("height", it0.height)
1024
+ it0.durationSec?.let { d -> putDouble("duration", d) }
1025
+ it0.thumbnailUri?.let { t ->
1026
+ putString("thumbnailUri", t)
1027
+ putInt("thumbnailWidth", it0.thumbnailWidth)
1028
+ putInt("thumbnailHeight", it0.thumbnailHeight)
1029
+ }
1030
+ })
1131
1031
  }
1132
- }
1133
- } finally {
1134
- currentVideoExporter = null
1135
- job.end()
1136
- }
1137
- results.add(resultMap)
1138
- try {
1139
- val copy = WritableNativeMap().apply { merge(resultMap) }
1140
- reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1141
- .emit("onImageProcessed", WritableNativeMap().apply {
1142
- putInt("total", totalCount); putInt("index", idx); putMap("image", copy)
1032
+ emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1143
1033
  putString("processingId", processingId)
1034
+ putInt("total", totalCount)
1035
+ putArray("items", arr)
1144
1036
  })
1145
- } catch (_: Exception) {}
1146
- currentIndex++
1147
- continue
1148
- }
1149
- try {
1150
- // 1. Get EXIF Orientation
1151
- var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
1152
- try {
1153
- contentResolver.openInputStream(uri)?.use { exifInputStream ->
1154
- val exif = android.media.ExifInterface(exifInputStream)
1155
- orientation = exif.getAttributeInt(android.media.ExifInterface.TAG_ORIENTATION, android.media.ExifInterface.ORIENTATION_NORMAL)
1156
1037
  }
1157
- } catch (e: Exception) {}
1158
1038
 
1159
- contentResolver.openInputStream(uri)?.use { inputStream ->
1160
- // Get original image dimensions
1161
- val originalOptions = BitmapFactory.Options().apply {
1162
- inJustDecodeBounds = true
1163
- }
1164
- BitmapFactory.decodeStream(inputStream, null, originalOptions)
1165
-
1166
- var originalWidth = originalOptions.outWidth
1167
- var originalHeight = originalOptions.outHeight
1168
-
1169
- // Swap width and height if rotated by 90 or 270 degrees
1170
- if (orientation == android.media.ExifInterface.ORIENTATION_ROTATE_90 || orientation == android.media.ExifInterface.ORIENTATION_ROTATE_270) {
1171
- val temp = originalWidth
1172
- originalWidth = originalHeight
1173
- originalHeight = temp
1039
+ override fun onVideoProgress(index: Int, progress: Float) {
1040
+ hud?.let { h -> main.post { h.update(progress, index, totalCount) } }
1041
+ emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1042
+ putInt("index", index); putInt("total", totalCount); putDouble("progress", progress.toDouble())
1043
+ putString("processingId", processingId)
1044
+ })
1174
1045
  }
1175
-
1176
- // Load and resize image
1177
- contentResolver.openInputStream(uri)?.use { resizedInputStream ->
1178
- // Initial decode for sizing
1179
- val decodeOptions = BitmapFactory.Options().apply {
1180
- inSampleSize = calculateInSampleSize(originalWidth, originalHeight, maxWidth, maxHeight)
1181
- }
1182
- var bitmap = BitmapFactory.decodeStream(resizedInputStream, null, decodeOptions)
1183
-
1184
- if (bitmap != null) {
1185
- // Apply EXIF Rotation
1186
- val matrix = android.graphics.Matrix()
1187
- when (orientation) {
1188
- android.media.ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
1189
- android.media.ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
1190
- android.media.ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
1191
- android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
1192
- android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
1193
- }
1194
-
1195
- if (!matrix.isIdentity) {
1196
- val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
1197
- if (rotatedBitmap != bitmap) {
1198
- bitmap.recycle()
1199
- bitmap = rotatedBitmap
1200
- }
1201
- }
1202
-
1203
- // Precise resize if needed
1204
- val finalBitmap = if (bitmap.width > maxWidth || bitmap.height > maxHeight) {
1205
- resizeBitmap(bitmap, maxWidth, maxHeight)
1206
- } else {
1207
- bitmap
1208
- }
1209
-
1210
- val processedBitmap = ImageProcessor.applyWatermarkIfNeeded(reactApplicationContext, finalBitmap)
1211
-
1212
- // Determine format and extension
1213
- val compressFormat: Bitmap.CompressFormat
1214
- val extension: String
1215
- val mimeType: String
1216
-
1217
- when (outputFormat) {
1218
- "png" -> {
1219
- compressFormat = Bitmap.CompressFormat.PNG
1220
- extension = "png"
1221
- mimeType = "image/png"
1222
- }
1223
- "jpg", "jpeg" -> {
1224
- compressFormat = Bitmap.CompressFormat.JPEG
1225
- extension = "jpg"
1226
- mimeType = "image/jpeg"
1227
- }
1228
- else -> { // webp
1229
- compressFormat = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
1230
- Bitmap.CompressFormat.WEBP_LOSSY
1231
- } else {
1232
- @Suppress("DEPRECATION")
1233
- Bitmap.CompressFormat.WEBP
1234
- }
1235
- extension = "webp"
1236
- mimeType = "image/webp"
1237
- }
1238
- }
1239
1046
 
1240
- // Save to temporary file
1241
- val tempFile = File.createTempFile("turbo_edited_${System.currentTimeMillis()}_", ".$extension", reactApplicationContext.cacheDir)
1242
- FileOutputStream(tempFile).use { out ->
1243
- processedBitmap.compress(compressFormat, 85, out)
1244
- }
1245
- // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서 — 원본을 다시 읽지 않음
1246
- val thumb = thumbnailOptions?.let {
1247
- com.rnattachmentkit.PhotoThumbnail.make(processedBitmap, it, reactApplicationContext.cacheDir, currentIndex)
1248
- }
1249
-
1250
- val finalWidth = processedBitmap.width
1251
- val finalHeight = processedBitmap.height
1252
-
1253
- // Cleanup
1254
- if (bitmap != processedBitmap) {
1255
- bitmap.recycle()
1256
- }
1257
- if (finalBitmap != processedBitmap && finalBitmap != bitmap) {
1258
- finalBitmap.recycle()
1259
- }
1260
- processedBitmap.recycle()
1261
-
1262
- val createImageInfo = {
1263
- WritableNativeMap().apply {
1264
- putString("uri", Uri.fromFile(tempFile).toString())
1265
- putInt("width", finalWidth)
1266
- putInt("height", finalHeight)
1267
- putString("type", mimeType)
1268
- putString("fileName", tempFile.name)
1269
- putString("fileExtension", extension)
1270
- putDouble("fileSize", tempFile.length().toDouble())
1271
- putString("originalUri", uri.toString())
1272
- putInt("originalWidth", originalWidth)
1273
- putInt("originalHeight", originalHeight)
1274
- if (thumb != null) {
1275
- putString("thumbnailUri", Uri.fromFile(thumb.file).toString())
1276
- putInt("thumbnailWidth", thumb.width)
1277
- putInt("thumbnailHeight", thumb.height)
1278
- putDouble("thumbnailFileSize", thumb.fileSize.toDouble())
1279
- }
1280
- }
1281
- }
1282
- val imageInfo = createImageInfo()
1283
- results.add(imageInfo)
1284
-
1285
- try {
1286
- val eventMap = WritableNativeMap().apply {
1287
- putInt("total", totalCount)
1288
- putInt("index", currentIndex)
1289
- putString("processingId", processingId)
1290
- putMap("image", createImageInfo())
1291
- }
1292
- reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1293
- .emit("onImageProcessed", eventMap)
1294
- } catch (e: Exception) { }
1295
- }
1047
+ override fun onItem(result: com.rnattachmentkit.AttachmentProcessor.Result) {
1048
+ if (result.isVideo) hud?.let { h -> main.post { h.update(1f, result.index, totalCount) } }
1049
+ try {
1050
+ emitter().emit("onImageProcessed", WritableNativeMap().apply {
1051
+ putInt("total", totalCount); putInt("index", result.index); putMap("image", toWritableMap(result))
1052
+ putString("processingId", processingId)
1053
+ })
1054
+ } catch (_: Exception) {}
1296
1055
  }
1297
- }
1298
- currentIndex++
1299
- } catch (e: Exception) {
1300
- currentIndex++
1301
- // Skip failed images
1302
- continue
1303
- }
1304
- }
1305
- } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1306
- // 취소: 지금까지 만든 동영상 파일은 지움
1307
- videoFiles.forEach { try { it.delete() } catch (_: Exception) {} }
1308
- throw e
1056
+ }).map { toWritableMap(it) }
1309
1057
  } finally {
1310
1058
  activeProcessingJobs.remove(processingId)
1311
1059
  if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
1312
1060
  }
1313
-
1314
- results
1315
1061
  }
1316
-
1317
- private fun calculateInSampleSize(
1318
- reqWidth: Int,
1319
- reqHeight: Int,
1320
- maxWidth: Int,
1321
- maxHeight: Int
1322
- ): Int {
1323
- var inSampleSize = 1
1324
-
1325
- if (reqHeight > maxHeight || reqWidth > maxWidth) {
1326
- val halfHeight = reqHeight / 2
1327
- val halfWidth = reqWidth / 2
1328
-
1329
- while ((halfHeight / inSampleSize) >= maxHeight && (halfWidth / inSampleSize) >= maxWidth) {
1330
- inSampleSize *= 2
1062
+
1063
+ /** AttachmentProcessor.Result → RN 결과 맵 (예전 브릿지와 같은 필드·타입) */
1064
+ private fun toWritableMap(r: com.rnattachmentkit.AttachmentProcessor.Result): WritableMap = WritableNativeMap().apply {
1065
+ r.toMap().forEach { (k, v) ->
1066
+ when (v) {
1067
+ is Boolean -> putBoolean(k, v)
1068
+ is Int -> putInt(k, v)
1069
+ is Number -> putDouble(k, v.toDouble())
1070
+ else -> putString(k, v.toString())
1331
1071
  }
1332
1072
  }
1333
-
1334
- return inSampleSize
1335
- }
1336
-
1337
- private fun resizeBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
1338
- val width = bitmap.width
1339
- val height = bitmap.height
1340
-
1341
- val ratioBitmap = width.toFloat() / height.toFloat()
1342
- val ratioMax = maxWidth.toFloat() / maxHeight.toFloat()
1343
-
1344
- var finalWidth = maxWidth
1345
- var finalHeight = maxHeight
1346
-
1347
- if (ratioMax > ratioBitmap) {
1348
- finalWidth = (maxHeight * ratioBitmap).toInt()
1349
- } else {
1350
- finalHeight = (maxWidth / ratioBitmap).toInt()
1351
- }
1352
-
1353
- return Bitmap.createScaledBitmap(bitmap, finalWidth, finalHeight, true)
1354
1073
  }
1355
1074
 
1356
1075