react-native-attachment-kit 1.0.18 → 1.1.1
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.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -2
- package/android/build.gradle +8 -3
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
- package/ios/RNAttachmentKit.swift +84 -771
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +6709 -449
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6709 -449
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6709 -449
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +20 -20
- package/package.json +1 -1
- package/src/index.ts +4 -3
- package/src/types.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.1 — 2026-11-03
|
|
4
|
+
|
|
5
|
+
- **Viewer: `sourceClipInsets` / `sourceClipRect` options — the zoom open / close stays behind translucent bars (iOS + Android `useOverlayViewer`)**. The moving photo and the `hideSourceImage` mask are drawn in the window above the app, so a thumbnail half under the app's translucent top bar flew back *over* the bar and the mask covered the bar too. With a clip area (screen-edge insets, a rect in `sourceRect` coordinates, or both = intersection) they are drawn only inside it: on open the photo starts cut at the bar and the clip widens to the full screen during the second half (the viewer itself is never cut); on close it narrows back in the first 20 % (the viewer background turns transparent at once on close, so the photo never covers the bar), and the part under the bar disappears behind it. Checked on the iOS simulator (HeyTalk feed): a cell half under the translucent top bar opens from behind the bar and closes back behind it; a cell clear of the bar animates as before. `updateSourceRect` takes the same keys (changed only when passed, `null` = no clip) for paging to another thumbnail. Without the options nothing changes.
|
|
6
|
+
- iOS: `ZoomTransitionAnimator.sourceClipRect` / `sourceClipInsets` (public) — the dummy image sits in a full-screen view whose `mask` view animates between the clip and the bounds; the source mask sits in a holder of the clip's size (`clipsToBounds`). Other users of the animator (gallery, media gallery) pass nothing.
|
|
7
|
+
- Android: `ImageViewerOverlayView` sets `clipBounds` on the dummy image (lerped with the open / close progress) and on the mask (its own coordinates, refreshed whenever the mask moves). Insets are measured from the window's decor view. The Activity / Dialog viewers ignore the options.
|
|
8
|
+
## 1.1.0 — 2026-10-27
|
|
9
|
+
|
|
10
|
+
- **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).
|
|
11
|
+
- 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.
|
|
12
|
+
- Android: `com.rnattachmentkit.AttachmentProcessor` (`process` / `processPhoto` / `processVideo` are `suspend`), `Options`, `Result` (`toMap()` = the RN result fields), `Listener`; `ProcessingJob.abort()` / `isAborted`.
|
|
13
|
+
- 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).
|
|
14
|
+
- **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).
|
|
15
|
+
- **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).
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
3
18
|
## 1.0.18 — 2026-10-23
|
|
4
19
|
|
|
5
20
|
- **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.
|
package/README.md
CHANGED
|
@@ -341,6 +341,8 @@ ref.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
|
341
341
|
| `sourceBorderCorners` | `('topLeft' \| 'topRight' \| 'bottomLeft' \| 'bottomRight')[]` | Which corners are rounded (chat-bubble groups). `[]` = none. |
|
|
342
342
|
| `sourceBackgroundColor` | `string` | Background behind the thumbnail during the animation. |
|
|
343
343
|
| `hideSourceImage` | `boolean` | A native mask hides the source thumbnail while the viewer is open. |
|
|
344
|
+
| `sourceClipInsets` | `{ top?, bottom?, left?, right? }` | Screen-edge insets of the area where the **moving photo and the `hideSourceImage` mask** are drawn during the zoom open / close (e.g. `{ top: headerHeight, bottom: tabBarHeight }` for translucent bars). A thumbnail half under a translucent bar then slides **behind** the bar instead of over it. The clip widens to the full screen in the second half of the open (when the viewer background covers the bar) and narrows in the first 20 % of the close, so the viewer itself is never cut. iOS + Android `useOverlayViewer`. |
|
|
345
|
+
| `sourceClipRect` | `SourceRect` | Same, as a rect in `sourceRect` coordinates (e.g. a list area inside a sheet). With both, the intersection is used. Omit both = no clipping (as before). |
|
|
344
346
|
|
|
345
347
|
**Actions**
|
|
346
348
|
|
|
@@ -368,8 +370,8 @@ ref.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
|
368
370
|
| `useDialogViewer: true` | DialogFragment — no `AppState` blip, but background RN layout changes may not render until it closes. |
|
|
369
371
|
| `useOverlayViewer: true` | ⭐ View on top of the host Activity's window — no `AppState` blip, background changes render in real time. Wins over `useDialogViewer`. |
|
|
370
372
|
|
|
371
|
-
#### `updateSourceRect(rect:
|
|
372
|
-
Updates where the viewer closes to (e.g. after paging to another thumbnail or scrolling).
|
|
373
|
+
#### `updateSourceRect(rect: ViewerSourceUpdate): Promise<void>`
|
|
374
|
+
Updates where the viewer closes to (e.g. after paging to another thumbnail or scrolling). `ViewerSourceUpdate = SourceRect & { sourceBorderRadius?, sourceBorderCorners?, sourceClipRect?, sourceClipInsets? }` — the clip keys change only when passed (`null` = stop clipping).
|
|
373
375
|
|
|
374
376
|
#### `injectImageCache(url: string, localPath: string): Promise<boolean>`
|
|
375
377
|
Seeds the viewer's image cache with a local file for a remote URL, so it opens instantly without network.
|
package/android/build.gradle
CHANGED
|
@@ -97,7 +97,10 @@ if (project != rootProject) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
dependencies {
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 쪽과 같은 버전)
|
|
Binary file
|
|
@@ -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 가 없을 때) */
|
|
@@ -214,12 +212,33 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
214
212
|
}
|
|
215
213
|
}
|
|
216
214
|
|
|
215
|
+
// 잘라내기 영역 — 넘긴 키만 바뀜 (useOverlayViewer 뷰어만 반영)
|
|
216
|
+
clipArrayOf(options, "sourceClipRect")?.let { intent.putExtra("sourceClipRect", it) }
|
|
217
|
+
clipArrayOf(options, "sourceClipInsets")?.let { intent.putExtra("sourceClipInsets", it) }
|
|
217
218
|
androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(reactApplicationContext)
|
|
218
219
|
.sendBroadcast(intent)
|
|
219
220
|
|
|
220
221
|
promise.resolve(null)
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
/**
|
|
225
|
+
* sourceClipRect {x,y,width,height} / sourceClipInsets {top,bottom,left,right} (dp) → 화면 px [l,t,r,b]
|
|
226
|
+
* (여백이면 각 변의 여백). 키가 없으면 null(안 바꿈), 값이 null 이면 빈 배열(자르지 않음).
|
|
227
|
+
*/
|
|
228
|
+
private fun clipArrayOf(options: ReadableMap, key: String): FloatArray? {
|
|
229
|
+
if (!options.hasKey(key)) return null
|
|
230
|
+
if (options.isNull(key)) return FloatArray(0)
|
|
231
|
+
val m = options.getMap(key) ?: return FloatArray(0)
|
|
232
|
+
fun px(k: String) = if (m.hasKey(k) && !m.isNull(k)) com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(m.getDouble(k).toFloat()) else 0f
|
|
233
|
+
return if (key == "sourceClipRect") {
|
|
234
|
+
val x = px("x")
|
|
235
|
+
val y = px("y")
|
|
236
|
+
floatArrayOf(x, y, x + px("width"), y + px("height"))
|
|
237
|
+
} else {
|
|
238
|
+
floatArrayOf(px("left"), px("top"), px("right"), px("bottom"))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
223
242
|
override fun getName(): String = NAME
|
|
224
243
|
|
|
225
244
|
override fun init(licenseKey: String, promise: Promise) {
|
|
@@ -666,6 +685,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
666
685
|
if (options.hasKey("sourceBackgroundColor")) {
|
|
667
686
|
putString("sourceBackgroundColor", options.getString("sourceBackgroundColor"))
|
|
668
687
|
}
|
|
688
|
+
// 잘라내기 영역 (열림·닫힘 중 사진과 가림 판이 그려지는 곳) — 이 오버레이 뷰어만
|
|
689
|
+
clipArrayOf(options, "sourceClipRect")?.let { putFloatArray("sourceClipRect", it) }
|
|
690
|
+
clipArrayOf(options, "sourceClipInsets")?.let { putFloatArray("sourceClipInsets", it) }
|
|
669
691
|
// placeholderImages: 타입/뷰어 코드에는 있었지만 브릿지에서 한 번도 전달되지 않았던
|
|
670
692
|
// 필드. 네이티브 뷰어가 onlyRetrieveFromCache(true)로 첫 프레임을 즉시 그리려 할 때
|
|
671
693
|
// 쓰는 저해상도 대체 이미지 목록입니다(원본 다운로드 전 임시 표시용).
|
|
@@ -985,374 +1007,93 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
985
1007
|
}
|
|
986
1008
|
}
|
|
987
1009
|
|
|
1010
|
+
/** 고른 뒤 처리 — com.rnattachmentkit.AttachmentProcessor (앱 네이티브 화면과 같은 코드). 여기선 RN 이벤트·HUD 만 */
|
|
988
1011
|
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
1012
|
val processingId = java.util.UUID.randomUUID().toString()
|
|
1000
|
-
val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
|
|
1001
|
-
val contentResolver = reactApplicationContext.contentResolver
|
|
1002
1013
|
val totalCount = uris.size
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
val hasVideo = uris.any(isVideoUri)
|
|
1010
|
-
videoCancelled = false
|
|
1014
|
+
val options = com.rnattachmentkit.AttachmentProcessor.Options(
|
|
1015
|
+
maxWidth = snap.maxWidth, maxHeight = snap.maxHeight, outputFormat = snap.outputFormat,
|
|
1016
|
+
thumbnail = snap.thumbnail, videoOptions = snap.videoOptions,
|
|
1017
|
+
)
|
|
1018
|
+
val hasVideo = uris.any { com.rnattachmentkit.AttachmentProcessor.isVideoUri(reactApplicationContext, it) }
|
|
1019
|
+
// 앱의 cancelVideoProcessing(index) → job.cancel, HUD 의 취소 → job.abort (전체 취소)
|
|
1011
1020
|
val job = com.rnattachmentkit.ProcessingJob()
|
|
1012
1021
|
activeProcessingJobs[processingId] = job
|
|
1013
1022
|
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
1023
|
var hud: com.rnattachmentkit.VideoExportHUD? = null
|
|
1047
|
-
if (hasVideo &&
|
|
1024
|
+
if (hasVideo && snap.showHUD) {
|
|
1048
1025
|
withContext(Dispatchers.Main) {
|
|
1049
1026
|
val act = getCurrentActivity() ?: return@withContext
|
|
1050
|
-
hud = com.rnattachmentkit.VideoExportHUD(act,
|
|
1051
|
-
h.onCancel = {
|
|
1052
|
-
videoCancelled = true
|
|
1053
|
-
currentVideoExporter?.cancel()
|
|
1054
|
-
}
|
|
1027
|
+
hud = com.rnattachmentkit.VideoExportHUD(act, snap.themeColor, snap.language).also { h ->
|
|
1028
|
+
h.onCancel = { job.abort() }
|
|
1055
1029
|
h.show()
|
|
1056
1030
|
}
|
|
1057
1031
|
}
|
|
1058
1032
|
}
|
|
1059
|
-
val
|
|
1060
|
-
|
|
1033
|
+
val main = android.os.Handler(android.os.Looper.getMainLooper())
|
|
1034
|
+
val emitter = { reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) }
|
|
1061
1035
|
try {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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")
|
|
1036
|
+
com.rnattachmentkit.AttachmentProcessor.process(reactApplicationContext, uris, snap.states, options, job,
|
|
1037
|
+
object : com.rnattachmentkit.AttachmentProcessor.Listener {
|
|
1038
|
+
// ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
|
|
1039
|
+
override fun onStart(items: List<com.rnattachmentkit.MediaProcessing.StartItem>) {
|
|
1040
|
+
val arr = Arguments.createArray()
|
|
1041
|
+
items.forEach { it0 ->
|
|
1042
|
+
arr.pushMap(Arguments.createMap().apply {
|
|
1043
|
+
putInt("index", it0.index)
|
|
1044
|
+
putString("mediaType", if (it0.isVideo) "video" else "image")
|
|
1045
|
+
putString("originalUri", it0.originalUri)
|
|
1046
|
+
putInt("width", it0.width)
|
|
1047
|
+
putInt("height", it0.height)
|
|
1048
|
+
it0.durationSec?.let { d -> putDouble("duration", d) }
|
|
1049
|
+
it0.thumbnailUri?.let { t ->
|
|
1050
|
+
putString("thumbnailUri", t)
|
|
1051
|
+
putInt("thumbnailWidth", it0.thumbnailWidth)
|
|
1052
|
+
putInt("thumbnailHeight", it0.thumbnailHeight)
|
|
1053
|
+
}
|
|
1054
|
+
})
|
|
1133
1055
|
}
|
|
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)
|
|
1056
|
+
emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
|
|
1145
1057
|
putString("processingId", processingId)
|
|
1058
|
+
putInt("total", totalCount)
|
|
1059
|
+
putArray("items", arr)
|
|
1146
1060
|
})
|
|
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
1061
|
}
|
|
1159
|
-
} catch (e: Exception) {}
|
|
1160
1062
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
|
1063
|
+
override fun onVideoProgress(index: Int, progress: Float) {
|
|
1064
|
+
hud?.let { h -> main.post { h.update(progress, index, totalCount) } }
|
|
1065
|
+
emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
|
|
1066
|
+
putInt("index", index); putInt("total", totalCount); putDouble("progress", progress.toDouble())
|
|
1067
|
+
putString("processingId", processingId)
|
|
1068
|
+
})
|
|
1176
1069
|
}
|
|
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
|
-
|
|
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
1070
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
}
|
|
1262
|
-
|
|
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
|
-
}
|
|
1071
|
+
override fun onItem(result: com.rnattachmentkit.AttachmentProcessor.Result) {
|
|
1072
|
+
if (result.isVideo) hud?.let { h -> main.post { h.update(1f, result.index, totalCount) } }
|
|
1073
|
+
try {
|
|
1074
|
+
emitter().emit("onImageProcessed", WritableNativeMap().apply {
|
|
1075
|
+
putInt("total", totalCount); putInt("index", result.index); putMap("image", toWritableMap(result))
|
|
1076
|
+
putString("processingId", processingId)
|
|
1077
|
+
})
|
|
1078
|
+
} catch (_: Exception) {}
|
|
1298
1079
|
}
|
|
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
|
|
1080
|
+
}).map { toWritableMap(it) }
|
|
1311
1081
|
} finally {
|
|
1312
1082
|
activeProcessingJobs.remove(processingId)
|
|
1313
1083
|
if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
|
|
1314
1084
|
}
|
|
1315
|
-
|
|
1316
|
-
results
|
|
1317
1085
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
|
1086
|
+
|
|
1087
|
+
/** AttachmentProcessor.Result → RN 결과 맵 (예전 브릿지와 같은 필드·타입) */
|
|
1088
|
+
private fun toWritableMap(r: com.rnattachmentkit.AttachmentProcessor.Result): WritableMap = WritableNativeMap().apply {
|
|
1089
|
+
r.toMap().forEach { (k, v) ->
|
|
1090
|
+
when (v) {
|
|
1091
|
+
is Boolean -> putBoolean(k, v)
|
|
1092
|
+
is Int -> putInt(k, v)
|
|
1093
|
+
is Number -> putDouble(k, v.toDouble())
|
|
1094
|
+
else -> putString(k, v.toString())
|
|
1333
1095
|
}
|
|
1334
1096
|
}
|
|
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
1097
|
}
|
|
1357
1098
|
|
|
1358
1099
|
|