react-native-attachment-kit 1.0.18 → 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 (28) hide show
  1. package/CHANGELOG.md +10 -0
  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 +58 -341
  5. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +58 -341
  6. package/ios/RNAttachmentKit.swift +64 -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 +6419 -422
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +107 -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 +107 -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/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6419 -422
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +107 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +107 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6419 -422
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +107 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +107 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +20 -20
  28. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-10-27
4
+
5
+ - **New public API for native screens: `AttachmentProcessor` (iOS framework + Android aar)** — the "after picking" processing that used to live only inside the RN bridge (photo resize and save as webp / jpg / png, photo thumbnail, video export with `VideoExporter` and its poster, the processing-start slots with predicted sizes and posters, progress throttling, per-video cancel, temp files and the result model). An app's own native screen can now open the kit gallery and process the result with exactly the same code as `openGallery`, so both produce the same files (size, thumbnail, poster).
6
+ - iOS: `AttachmentProcessor.process(_:videoStates:options:job:onStart:onVideoProgress:onItem:)`, `startItem`, `processPhoto`, `processVideo`, and the helpers (`resizeImage`, `saveImage`, `encodeWebP`, `makePhotoThumbnail`, `photoThumbnailLayout`, `videoFrame`, `requestPoster`, `prepareCacheDirectory` …). Models `AttachmentProcessingOptions`, `AttachmentStartItem`, `AttachmentResult` (`.dictionary` = the RN result fields), `AttachmentProcessingJob` (`cancel(index:)` / `abort()`), `AttachmentProgressThrottle`; `PhotoThumbnailOptions` is now public.
7
+ - Android: `com.rnattachmentkit.AttachmentProcessor` (`process` / `processPhoto` / `processVideo` are `suspend`), `Options`, `Result` (`toMap()` = the RN result fields), `Listener`; `ProcessingJob.abort()` / `isAborted`.
8
+ - The RN bridge (`openGallery` on both platforms, iOS `openEditor` video / photo paths) now calls these — no change in the JS API, events or result fields. The progress HUD's cancel is `job.abort()` (whole send cancelled, Promise rejected as before).
9
+ - **Android: the aar is exposed to the app (`api`)**, together with `material` and `media3-transformer` (supertypes / signatures of public classes). An app's native code can use the kit classes without its own `compileOnly` copy of the aar. Class names are kept by the aar's `-keep class com.rnattachmentkit.** { *; }`; apps that minify keep the same rule in their `proguard-rules.pro` (HeyTalk / ZemTalk already do).
10
+ - **License: `LicenseManager.isInitialized` and `whenInitialized(timeout:)` (iOS + Android)** — lets a native screen wait for the RN side's `AttachmentKit.init` before it opens the gallery or processes anything (the gallery's video permission and the photo watermark read the license).
11
+ - Checked in HeyTalk (new devices `HeyTalk_KIT` simulator / emulator, one batch of a 4032×3024 jpg + a 1080p 6 s mp4): on iOS and on Android the RN `openGallery` path and the app's native chat room produced byte-identical photo webp (1024×768), processing-start poster and export poster, and mp4s with the same codec, size, duration, frame count, bitrate and byte count (only the mp4 creation time differs). An Android photo bug introduced while moving the code (bounds-only decode treated as failure, so every photo was dropped) was found by that check and fixed before this build. ZemTalk: iOS device build (no signing) and Android Kotlin/Java compile pass with no change to ZemTalk.
12
+
3
13
  ## 1.0.18 — 2026-10-23
4
14
 
5
15
  - **Image viewer (iOS) and full-screen camera (iOS + Android): pull-down follows the finger back up freely**, like the editor. Before, dragging down and then back above the starting point made the photo / camera stop at a fixed height.
@@ -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 가 없을 때) */
@@ -985,374 +983,93 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
985
983
  }
986
984
  }
987
985
 
986
+ /** 고른 뒤 처리 — com.rnattachmentkit.AttachmentProcessor (앱 네이티브 화면과 같은 코드). 여기선 RN 이벤트·HUD 만 */
988
987
  private suspend fun processImages(uris: List<Uri>, snap: SendSnapshot): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
989
- // 보낸 순간의 설정 (모듈 필드와 같은 이름으로 가림 — 아래 코드는 그대로)
990
- val maxWidth = snap.maxWidth
991
- val maxHeight = snap.maxHeight
992
- val outputFormat = snap.outputFormat
993
- val videoOptions = snap.videoOptions
994
- val lastVideoEditStates = snap.states
995
- val showVideoHUD = snap.showHUD
996
- val lastThemeColorInt = snap.themeColor
997
- val lastLanguageCode = snap.language
998
- val thumbnailOptions = snap.thumbnail
999
988
  val processingId = java.util.UUID.randomUUID().toString()
1000
- val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
1001
- val contentResolver = reactApplicationContext.contentResolver
1002
989
  val totalCount = uris.size
1003
- var currentIndex = 0
1004
-
1005
- // 동영상이 섞여 있으면 진행 HUD 를 띄우고, 동영상은 VideoExporter 로 (사진 처리와 같은 루프)
1006
- val isVideoUri: (Uri) -> Boolean = { u ->
1007
- u.toString().contains("/video/") || (try { contentResolver.getType(u)?.startsWith("video/") == true } catch (_: Exception) { false })
1008
- }
1009
- val hasVideo = uris.any(isVideoUri)
1010
- 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 (전체 취소)
1011
996
  val job = com.rnattachmentkit.ProcessingJob()
1012
997
  activeProcessingJobs[processingId] = job
1013
998
  lastProcessingId = processingId
1014
- // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1015
- try {
1016
- val items = Arguments.createArray()
1017
- uris.forEachIndexed { i, u ->
1018
- val it0 = if (isVideoUri(u)) {
1019
- val st = lastVideoEditStates[u.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1020
- com.rnattachmentkit.MediaProcessing.videoItem(reactApplicationContext, i, u, st, videoOptions)
1021
- } else {
1022
- com.rnattachmentkit.MediaProcessing.imageItem(reactApplicationContext, i, u, maxWidth, maxHeight)
1023
- }
1024
- items.pushMap(Arguments.createMap().apply {
1025
- putInt("index", it0.index)
1026
- putString("mediaType", if (it0.isVideo) "video" else "image")
1027
- putString("originalUri", it0.originalUri)
1028
- putInt("width", it0.width)
1029
- putInt("height", it0.height)
1030
- it0.durationSec?.let { d -> putDouble("duration", d) }
1031
- it0.thumbnailUri?.let { t ->
1032
- putString("thumbnailUri", t)
1033
- putInt("thumbnailWidth", it0.thumbnailWidth)
1034
- putInt("thumbnailHeight", it0.thumbnailHeight)
1035
- }
1036
- })
1037
- }
1038
- emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1039
- putString("processingId", processingId)
1040
- putInt("total", totalCount)
1041
- putArray("items", items)
1042
- })
1043
- } catch (e: Exception) {
1044
- android.util.Log.w("RNAttachmentKit", "onProcessingStart failed: ${e.message}")
1045
- }
1046
999
  var hud: com.rnattachmentkit.VideoExportHUD? = null
1047
- if (hasVideo && showVideoHUD) {
1000
+ if (hasVideo && snap.showHUD) {
1048
1001
  withContext(Dispatchers.Main) {
1049
1002
  val act = getCurrentActivity() ?: return@withContext
1050
- hud = com.rnattachmentkit.VideoExportHUD(act, lastThemeColorInt, lastLanguageCode).also { h ->
1051
- h.onCancel = {
1052
- videoCancelled = true
1053
- currentVideoExporter?.cancel()
1054
- }
1003
+ hud = com.rnattachmentkit.VideoExportHUD(act, snap.themeColor, snap.language).also { h ->
1004
+ h.onCancel = { job.abort() }
1055
1005
  h.show()
1056
1006
  }
1057
1007
  }
1058
1008
  }
1059
- val videoFiles = mutableListOf<File>()
1060
-
1009
+ val main = android.os.Handler(android.os.Looper.getMainLooper())
1010
+ val emitter = { reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) }
1061
1011
  try {
1062
- for (uri in uris) {
1063
- if (videoCancelled) throw com.rnattachmentkit.VideoExporter.CancelledException()
1064
- if (isVideoUri(uri)) {
1065
- val idx = currentIndex
1066
- val exporter = com.rnattachmentkit.VideoExporter(reactApplicationContext)
1067
- currentVideoExporter = exporter
1068
- val cancelledMap = {
1069
- WritableNativeMap().apply {
1070
- putString("originalUri", uri.toString())
1071
- putString("mediaType", "video")
1072
- putString("error", "CANCELLED")
1073
- putBoolean("cancelled", true)
1074
- }
1075
- }
1076
- val resultMap = try {
1077
- // 앱이 이미 취소한 동영상은 건너뜀
1078
- if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1079
- job.begin(idx, exporter)
1080
- val state = lastVideoEditStates[uri.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1081
- val throttle = com.rnattachmentkit.ProgressThrottle()
1082
- val onProgress: (Float) -> Unit = { p ->
1083
- hud?.let { h -> android.os.Handler(android.os.Looper.getMainLooper()).post { h.update(p, idx, totalCount) } }
1084
- if (throttle.shouldSend(p)) emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1085
- putInt("index", idx); putInt("total", totalCount); putDouble("progress", p.toDouble())
1086
- putString("processingId", processingId)
1087
- })
1088
- }
1089
- // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1090
- // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1091
- val stop = { videoCancelled || job.isCancelled(idx) }
1092
- val res = com.rnattachmentkit.VideoPrepareManager.takeReady(uri, state, onProgress, stop)
1093
- ?: run {
1094
- if (stop()) throw com.rnattachmentkit.VideoExporter.CancelledException()
1095
- exporter.export(uri, state, videoOptions, reactApplicationContext.cacheDir, "turbo_video_${System.currentTimeMillis()}_$idx", onProgress)
1096
- }
1097
- withContext(Dispatchers.Main) { hud?.update(1f, idx, totalCount) }
1098
- // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1099
- if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1100
- videoFiles += res.file
1101
- res.thumbnailFile?.let { videoFiles += it }
1102
- WritableNativeMap().apply {
1103
- putString("uri", Uri.fromFile(res.file).toString())
1104
- putString("originalUri", uri.toString())
1105
- putString("mediaType", "video")
1106
- putString("type", "video/mp4")
1107
- putInt("width", res.width)
1108
- putInt("height", res.height)
1109
- putDouble("duration", res.durationMs / 1000.0)
1110
- putDouble("fileSize", res.fileSize.toDouble())
1111
- putString("fileName", res.file.name)
1112
- putString("fileExtension", "mp4")
1113
- putString("videoCodec", res.codec)
1114
- putInt("videoBitrate", res.bitrate)
1115
- putBoolean("edited", res.edited)
1116
- if (res.thumbnailFile != null) {
1117
- putString("thumbnailUri", Uri.fromFile(res.thumbnailFile).toString())
1118
- putInt("thumbnailWidth", res.thumbnailWidth)
1119
- putInt("thumbnailHeight", res.thumbnailHeight)
1120
- }
1121
- }
1122
- } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1123
- // 앱이 이 동영상만 취소 → 나머지는 계속. HUD 취소면 전체 취소
1124
- if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else throw e
1125
- } catch (e: Exception) {
1126
- // 취소하면서 변환기가 다른 예외로 끝날 수도 있음 → 취소로 처리
1127
- if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else {
1128
- android.util.Log.w("RNAttachmentKit", "video export failed: ${e.message}", e)
1129
- WritableNativeMap().apply {
1130
- putString("originalUri", uri.toString())
1131
- putString("mediaType", "video")
1132
- 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
+ })
1133
1031
  }
1134
- }
1135
- } finally {
1136
- currentVideoExporter = null
1137
- job.end()
1138
- }
1139
- results.add(resultMap)
1140
- try {
1141
- val copy = WritableNativeMap().apply { merge(resultMap) }
1142
- reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1143
- .emit("onImageProcessed", WritableNativeMap().apply {
1144
- putInt("total", totalCount); putInt("index", idx); putMap("image", copy)
1032
+ emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1145
1033
  putString("processingId", processingId)
1034
+ putInt("total", totalCount)
1035
+ putArray("items", arr)
1146
1036
  })
1147
- } catch (_: Exception) {}
1148
- currentIndex++
1149
- continue
1150
- }
1151
- try {
1152
- // 1. Get EXIF Orientation
1153
- var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
1154
- try {
1155
- contentResolver.openInputStream(uri)?.use { exifInputStream ->
1156
- val exif = android.media.ExifInterface(exifInputStream)
1157
- orientation = exif.getAttributeInt(android.media.ExifInterface.TAG_ORIENTATION, android.media.ExifInterface.ORIENTATION_NORMAL)
1158
1037
  }
1159
- } catch (e: Exception) {}
1160
1038
 
1161
- contentResolver.openInputStream(uri)?.use { inputStream ->
1162
- // Get original image dimensions
1163
- val originalOptions = BitmapFactory.Options().apply {
1164
- inJustDecodeBounds = true
1165
- }
1166
- BitmapFactory.decodeStream(inputStream, null, originalOptions)
1167
-
1168
- var originalWidth = originalOptions.outWidth
1169
- var originalHeight = originalOptions.outHeight
1170
-
1171
- // Swap width and height if rotated by 90 or 270 degrees
1172
- if (orientation == android.media.ExifInterface.ORIENTATION_ROTATE_90 || orientation == android.media.ExifInterface.ORIENTATION_ROTATE_270) {
1173
- val temp = originalWidth
1174
- originalWidth = originalHeight
1175
- 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
+ })
1176
1045
  }
1177
-
1178
- // Load and resize image
1179
- contentResolver.openInputStream(uri)?.use { resizedInputStream ->
1180
- // Initial decode for sizing
1181
- val decodeOptions = BitmapFactory.Options().apply {
1182
- inSampleSize = calculateInSampleSize(originalWidth, originalHeight, maxWidth, maxHeight)
1183
- }
1184
- var bitmap = BitmapFactory.decodeStream(resizedInputStream, null, decodeOptions)
1185
-
1186
- if (bitmap != null) {
1187
- // Apply EXIF Rotation
1188
- val matrix = android.graphics.Matrix()
1189
- when (orientation) {
1190
- android.media.ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
1191
- android.media.ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
1192
- android.media.ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
1193
- android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
1194
- android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
1195
- }
1196
-
1197
- if (!matrix.isIdentity) {
1198
- val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
1199
- if (rotatedBitmap != bitmap) {
1200
- bitmap.recycle()
1201
- bitmap = rotatedBitmap
1202
- }
1203
- }
1204
-
1205
- // Precise resize if needed
1206
- val finalBitmap = if (bitmap.width > maxWidth || bitmap.height > maxHeight) {
1207
- resizeBitmap(bitmap, maxWidth, maxHeight)
1208
- } else {
1209
- bitmap
1210
- }
1211
-
1212
- val processedBitmap = ImageProcessor.applyWatermarkIfNeeded(reactApplicationContext, finalBitmap)
1213
-
1214
- // Determine format and extension
1215
- val compressFormat: Bitmap.CompressFormat
1216
- val extension: String
1217
- val mimeType: String
1218
-
1219
- when (outputFormat) {
1220
- "png" -> {
1221
- compressFormat = Bitmap.CompressFormat.PNG
1222
- extension = "png"
1223
- mimeType = "image/png"
1224
- }
1225
- "jpg", "jpeg" -> {
1226
- compressFormat = Bitmap.CompressFormat.JPEG
1227
- extension = "jpg"
1228
- mimeType = "image/jpeg"
1229
- }
1230
- else -> { // webp
1231
- compressFormat = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
1232
- Bitmap.CompressFormat.WEBP_LOSSY
1233
- } else {
1234
- @Suppress("DEPRECATION")
1235
- Bitmap.CompressFormat.WEBP
1236
- }
1237
- extension = "webp"
1238
- mimeType = "image/webp"
1239
- }
1240
- }
1241
1046
 
1242
- // Save to temporary file
1243
- val tempFile = File.createTempFile("turbo_edited_${System.currentTimeMillis()}_", ".$extension", reactApplicationContext.cacheDir)
1244
- FileOutputStream(tempFile).use { out ->
1245
- processedBitmap.compress(compressFormat, 85, out)
1246
- }
1247
- // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서 — 원본을 다시 읽지 않음
1248
- val thumb = thumbnailOptions?.let {
1249
- com.rnattachmentkit.PhotoThumbnail.make(processedBitmap, it, reactApplicationContext.cacheDir, currentIndex)
1250
- }
1251
-
1252
- val finalWidth = processedBitmap.width
1253
- val finalHeight = processedBitmap.height
1254
-
1255
- // Cleanup
1256
- if (bitmap != processedBitmap) {
1257
- bitmap.recycle()
1258
- }
1259
- if (finalBitmap != processedBitmap && finalBitmap != bitmap) {
1260
- finalBitmap.recycle()
1261
- }
1262
- processedBitmap.recycle()
1263
-
1264
- val createImageInfo = {
1265
- WritableNativeMap().apply {
1266
- putString("uri", Uri.fromFile(tempFile).toString())
1267
- putInt("width", finalWidth)
1268
- putInt("height", finalHeight)
1269
- putString("type", mimeType)
1270
- putString("fileName", tempFile.name)
1271
- putString("fileExtension", extension)
1272
- putDouble("fileSize", tempFile.length().toDouble())
1273
- putString("originalUri", uri.toString())
1274
- putInt("originalWidth", originalWidth)
1275
- putInt("originalHeight", originalHeight)
1276
- if (thumb != null) {
1277
- putString("thumbnailUri", Uri.fromFile(thumb.file).toString())
1278
- putInt("thumbnailWidth", thumb.width)
1279
- putInt("thumbnailHeight", thumb.height)
1280
- putDouble("thumbnailFileSize", thumb.fileSize.toDouble())
1281
- }
1282
- }
1283
- }
1284
- val imageInfo = createImageInfo()
1285
- results.add(imageInfo)
1286
-
1287
- try {
1288
- val eventMap = WritableNativeMap().apply {
1289
- putInt("total", totalCount)
1290
- putInt("index", currentIndex)
1291
- putString("processingId", processingId)
1292
- putMap("image", createImageInfo())
1293
- }
1294
- reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1295
- .emit("onImageProcessed", eventMap)
1296
- } catch (e: Exception) { }
1297
- }
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) {}
1298
1055
  }
1299
- }
1300
- currentIndex++
1301
- } catch (e: Exception) {
1302
- currentIndex++
1303
- // Skip failed images
1304
- continue
1305
- }
1306
- }
1307
- } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1308
- // 취소: 지금까지 만든 동영상 파일은 지움
1309
- videoFiles.forEach { try { it.delete() } catch (_: Exception) {} }
1310
- throw e
1056
+ }).map { toWritableMap(it) }
1311
1057
  } finally {
1312
1058
  activeProcessingJobs.remove(processingId)
1313
1059
  if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
1314
1060
  }
1315
-
1316
- results
1317
1061
  }
1318
-
1319
- private fun calculateInSampleSize(
1320
- reqWidth: Int,
1321
- reqHeight: Int,
1322
- maxWidth: Int,
1323
- maxHeight: Int
1324
- ): Int {
1325
- var inSampleSize = 1
1326
-
1327
- if (reqHeight > maxHeight || reqWidth > maxWidth) {
1328
- val halfHeight = reqHeight / 2
1329
- val halfWidth = reqWidth / 2
1330
-
1331
- while ((halfHeight / inSampleSize) >= maxHeight && (halfWidth / inSampleSize) >= maxWidth) {
1332
- 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())
1333
1071
  }
1334
1072
  }
1335
-
1336
- return inSampleSize
1337
- }
1338
-
1339
- private fun resizeBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
1340
- val width = bitmap.width
1341
- val height = bitmap.height
1342
-
1343
- val ratioBitmap = width.toFloat() / height.toFloat()
1344
- val ratioMax = maxWidth.toFloat() / maxHeight.toFloat()
1345
-
1346
- var finalWidth = maxWidth
1347
- var finalHeight = maxHeight
1348
-
1349
- if (ratioMax > ratioBitmap) {
1350
- finalWidth = (maxHeight * ratioBitmap).toInt()
1351
- } else {
1352
- finalHeight = (maxWidth / ratioBitmap).toInt()
1353
- }
1354
-
1355
- return Bitmap.createScaledBitmap(bitmap, finalWidth, finalHeight, true)
1356
1073
  }
1357
1074
 
1358
1075