react-native-attachment-kit 1.0.5 → 1.0.8
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 +18 -0
- package/README.md +100 -3
- package/android/build.gradle +14 -0
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +280 -13
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +282 -13
- package/ios/RNAttachmentKit.m +18 -0
- package/ios/RNAttachmentKit.swift +447 -9
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +21 -5
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Info.plist +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +33440 -7566
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +193 -5
- 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 +193 -5
- 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 +15 -15
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +42 -10
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +33440 -7566
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +193 -5
- 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 +193 -5
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +33440 -7566
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +193 -5
- 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 +193 -5
- 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 +25 -25
- package/package.json +2 -2
- package/react-native-attachment-kit.podspec +1 -1
- package/src/NativeAttachmentKit.ts +5 -0
- package/src/index.ts +118 -7
- package/src/types.ts +173 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.8 — 2026-10-16
|
|
4
|
+
|
|
5
|
+
- **AI screens are the same in every app (iOS + Android)**. Generate: woman / man, country, age (up to the 80s), style, situation, aspect ratio + Create, sample chips and the chat input; chat text is sent together with the chosen settings, and tapping a sample shows a summary of the settings before creating. The glamour situations / samples and the detail options were removed from the generate screen. Edit: the full editing screen everywhere. Generated and edited images are stored for the gallery's "AI 이미지" tab in every app.
|
|
6
|
+
- iOS: the "AI 이미지" tab expands / collapses the sheet like the gallery grid.
|
|
7
|
+
- iOS: an edited (trimmed) video's first poster (`onProcessingStart` `thumbnailUri`) is taken from the trimmed start, with crop and drawing applied.
|
|
8
|
+
- iOS: closing the AI screen right after using a menu no longer leaves a stray button over the gallery.
|
|
9
|
+
- Android image viewer: video controls no longer flash on photo pages while the viewer opens.
|
|
10
|
+
|
|
11
|
+
## 1.0.6 — 2026-10-14
|
|
12
|
+
|
|
13
|
+
- **Video in the picker, editor and viewer (iOS + Android)**: `openGallery({ mediaType: "all" | "video", video })` picks videos; the editor gets video pages (trim, mute, quality, crop, drawing/stickers/text, pre-encoding); `openViewer({ images, videos })` plays videos (progress pill, mute/options, remote video cache, pinch zoom). Sending progress is reported as events. `video.posterFormat` (`"webp"` default / `"jpg"`). Video limits are checked before sending.
|
|
14
|
+
- **In-app full-screen camera (iOS + Android)**: photo + hold-to-record video (with lock), ratio menu (꽉 참 / 9:16 / 3:4 / 1:1), resolution HD / FHD (default) / 4K, timer, grid, level, tap focus + exposure, stabilization, 30/60 fps, front screen flash, selfie mirror. The screen stays portrait; captures follow how the phone is held. Default lens 1×, smooth pinch zoom across lenses. The podspec now also links `CoreMotion`.
|
|
15
|
+
- **AI (Gemini) generate / edit**: `setAIConfig(...)`; generation screen, natural-language editing in the editor, recent works. Full features only in licensed (ZemTalk) apps; other apps get chat-only generate/edit.
|
|
16
|
+
- **Gallery attach menu "AI 이미지" tab**: shown when AI-made or AI-edited images exist; opens them in the image viewer (save, share, delete).
|
|
17
|
+
- **Image viewer video controls** readable in light mode (follow the Liquid Glass light/dark state).
|
|
18
|
+
- **Android performance**: the gallery opens without waiting for the camera; the camera cell is no longer rebound on scroll, tab change or editor pull-down; the editor zoom lands exactly on video pages.
|
|
19
|
+
- **iOS**: no empty-cell flash when a gallery photo opens in the editor; the AI images viewer no longer shows a red cell slot while open.
|
|
20
|
+
|
|
3
21
|
## 1.0.5 — 2026-10-10
|
|
4
22
|
|
|
5
23
|
- iOS map viewer: Directions is a menu anchored above the button (the action sheet floated at the top on iOS 26); TMAP added (Korean only, with Kakao Map / Naver Map); cancelling the system "Open in …?" prompt no longer falls through to the web map (which opened the app anyway). Uses `canOpenURL` when the host declares `comgooglemaps`, `kakaomap`, `nmap`, `tmap` in `LSApplicationQueriesSchemes`.
|
package/README.md
CHANGED
|
@@ -149,13 +149,16 @@ Opens the gallery bottom sheet. Resolves with the picked images (`[]` when the u
|
|
|
149
149
|
| `outputFormat` | `'webp' \| 'jpg' \| 'png'` | `'webp'` | Output file format. (Not in the TS type yet — pass it with a cast.) |
|
|
150
150
|
| `themeColor` / `languageCode` / `appearance` | | defaults | Per-call overrides. |
|
|
151
151
|
| `doneButtonText` / `allItemsText` / `selectedItemsText` / `recentsAlbumText` | `string` | translated | Label overrides. |
|
|
152
|
-
| `asyncProcessing` | `boolean` | `false` |
|
|
152
|
+
| `asyncProcessing` | `boolean` | `false` | Event-driven sending: each item is delivered through `onImageProcessed` as soon as it is ready (the Promise still resolves with everything at the end), and videos show no progress HUD — draw progress in your own UI. See [Sending progress](#sending-progress-events). |
|
|
153
153
|
| `onSelectionChange` | `(e: SelectionChangeEvent) => void` | — | `{ selectedCount, maxSelection }` on every change. |
|
|
154
|
-
| `
|
|
154
|
+
| `onProcessingStart` | `(e: ProcessingStartEvent) => void` | — | Once, right after Send, before any processing. |
|
|
155
|
+
| `onImageProcessed` | `(e) => void` | — | Per item `{ index, total, image }` as soon as it is ready. |
|
|
156
|
+
| `mediaType` | `'photo' \| 'all' \| 'video'` | `'photo'` | `'all'` mixes photos and videos. |
|
|
157
|
+
| `video` | `VideoOptions` | — | `maxDurationSec`, `maxFileSizeMB`, `qualities`, `defaultQuality`, `codec`, `onProgress`, `showProgressHUD`. |
|
|
155
158
|
| `openDuration` / `closeDuration` | `number` (ms) | native | Animation durations. |
|
|
156
159
|
| `showAttachMenu` … | | | Attach menu options — see [Attach menu](#attach-menu-location--file). |
|
|
157
160
|
|
|
158
|
-
`
|
|
161
|
+
`quality` is accepted for compatibility but currently ignored (output quality is fixed).
|
|
159
162
|
|
|
160
163
|
#### `ImageResult`
|
|
161
164
|
|
|
@@ -176,6 +179,26 @@ Closes the picker from code. `true` if it was open.
|
|
|
176
179
|
#### `addSelectionChangeListener(listener): () => void`
|
|
177
180
|
Selection events outside `openGallery`; returns an unsubscribe function.
|
|
178
181
|
|
|
182
|
+
#### Sending progress (events)
|
|
183
|
+
|
|
184
|
+
A chat can show every sent item at once and fill it in as it finishes (photos are resized, videos are encoded), the same way for photos and videos:
|
|
185
|
+
|
|
186
|
+
1. **`onProcessingStart`** `{ total, items }`: once, right after Send. Each item has `index`, `mediaType` (`'image' | 'video'`), `originalUri`, and the expected output `width` / `height` (for the aspect ratio). Videos also have `duration` (seconds, after trim) and `thumbnailUri` / `thumbnailWidth` / `thumbnailHeight` (a poster jpg with crop and drawing applied). Create the placeholders here.
|
|
187
|
+
2. **`onVideoProgress`** `{ index, total, progress }`: video encoding 0…1 (at most every 1% / 0.1 s). Draw a progress ring on that placeholder.
|
|
188
|
+
3. **`onImageProcessed`** `{ index, total, image }`: the item is ready. Replace the placeholder with `image.uri`. A video the app cancelled comes as `{ error: 'CANCELLED', cancelled: true }`; a failed one has `error`.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const sub = emitter.addListener('onProcessingStart', ({ items }) => addPlaceholders(items))
|
|
192
|
+
const prog = emitter.addListener('onVideoProgress', ({ index, progress }) => setProgress(index, progress))
|
|
193
|
+
const done = emitter.addListener('onImageProcessed', ({ index, image }) => fillPlaceholder(index, image))
|
|
194
|
+
await AttachmentKit.openGallery({ mediaType: 'all', asyncProcessing: true, enableEditor: true })
|
|
195
|
+
// or pass onProcessingStart / onImageProcessed / video.onProgress as options
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- **HUD:** `video.showProgressHUD` sets the full-screen progress HUD. The default is `true`, but `false` when `asyncProcessing: true`.
|
|
199
|
+
- **Cancel:** without the HUD, cancel from your UI with `AttachmentKit.cancelVideoProcessing(index?, processingId?)`. No `index` cancels all remaining videos; no `processingId` targets the latest send. The rest keep going, and the cancelled item is reported as above. It returns `false` when nothing is in progress.
|
|
200
|
+
- **Sending again while a video encodes:** the gallery can be opened again as soon as `onProcessingStart` arrives; you don't have to wait for the Promise, which still resolves with everything at the end. Every event carries `processingId`, so an app that listens with global listeners should use it to tell sends apart (`index` restarts at 0 for each send). Callbacks passed in the options are already filtered to their own send.
|
|
201
|
+
|
|
179
202
|
---
|
|
180
203
|
|
|
181
204
|
### Attach menu (Location / File)
|
|
@@ -438,6 +461,80 @@ A native grid built for thousands of photos. It opens like the picker (same bott
|
|
|
438
461
|
|
|
439
462
|
---
|
|
440
463
|
|
|
464
|
+
### AI (generate & edit)
|
|
465
|
+
|
|
466
|
+
The gallery's ✦ button (AI image generation) and the editor's 6th tool **AI** (AI photo editing) use Google Gemini. They are **off until you call `setAIConfig({ enabled: true, ... })`**. The screens are the same in every app. Generate has woman / man, country, age, style and situation pickers, an aspect-ratio bar, sample chips and the chat input; chat text is sent together with the chosen settings. Edit is the full editing screen: edit choices, options, recommendation chips and the chat input. Generated and edited images are kept on the device and listed in the gallery's "AI 이미지" tab.
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
await AttachmentKit.setAIConfig({
|
|
470
|
+
enabled: true,
|
|
471
|
+
endpoint: "https://api.example.com/ai/generate", // your server: forwards the Gemini generateContent body with your key
|
|
472
|
+
headers: { Authorization: `Bearer ${token}` }, // header X-AI-Model carries the model name
|
|
473
|
+
points: 1200, // current points
|
|
474
|
+
freeCount: 27, // subscribers: free uses left (e.g. 30 per month — your server decides); used before points
|
|
475
|
+
costPerUse: 100, // points per use (0 = free / unlimited)
|
|
476
|
+
highQualityCost: 150, // points per use with Quality "High (1K)" (default: costPerUse)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
const offUsage = AttachmentKit.addAIUsageListener(e => {
|
|
480
|
+
// { kind: "generate" | "edit", usedFreeCount, pointsDeducted, highQuality, points, freeCount }
|
|
481
|
+
api.reportAIUsage(e)
|
|
482
|
+
})
|
|
483
|
+
const offCharge = AttachmentKit.addAIChargeRequiredListener(e => {
|
|
484
|
+
// { kind, required, points, freeCount } — the AI screen has closed
|
|
485
|
+
AttachmentKit.closeGallery(); navigation.navigate("PointShop")
|
|
486
|
+
})
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
- Only the values you pass change; call it again whenever the balance changes (after a top-up, after a server sync). An open AI screen updates at once.
|
|
490
|
+
- Order of use: free uses → points → when points are short the user sees "Not enough points · Top up" (Top up → `onAIChargeRequired`, AI screen closes).
|
|
491
|
+
- Only successful requests are charged (failures, cancel and safety blocks are free). The module lowers its own copy and sends `onAIUsage`; your server stays the source of truth.
|
|
492
|
+
- The AI screen title shows "N free left" or "points · cost per use".
|
|
493
|
+
- `apiKey` exists for testing only — never ship a Gemini key in the app.
|
|
494
|
+
|
|
495
|
+
#### Your backend (the `endpoint`)
|
|
496
|
+
|
|
497
|
+
The module never needs a Gemini key in the app. Point `endpoint` at **your own server**; it only has to pass the request through to Gemini with your key.
|
|
498
|
+
|
|
499
|
+
**Request the module sends**
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
POST {endpoint}
|
|
503
|
+
Content-Type: application/json
|
|
504
|
+
X-AI-Model: gemini-3.1-flash-image ← model to call (image: gemini-3.1-flash-image / gemini-3.1-flash-lite-image, photo analysis: gemini-3.5-flash-lite)
|
|
505
|
+
…plus every header you passed in setAIConfig({ headers }) ← e.g. Authorization
|
|
506
|
+
|
|
507
|
+
{ …Gemini generateContent request body… }
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**What your server does**
|
|
511
|
+
|
|
512
|
+
1. Check the user (your `headers`) and that `X-AI-Model` is a model you allow.
|
|
513
|
+
2. Forward the body unchanged to `https://generativelanguage.googleapis.com/v1beta/models/{X-AI-Model}:generateContent` with header `x-goog-api-key: <your key>`.
|
|
514
|
+
3. Return Gemini's status code and JSON body **as is**.
|
|
515
|
+
|
|
516
|
+
```js
|
|
517
|
+
// Minimal Express example
|
|
518
|
+
app.post('/ai/generate', auth, express.json({ limit: '20mb' }), async (req, res) => {
|
|
519
|
+
const model = req.get('X-AI-Model');
|
|
520
|
+
if (!ALLOWED_MODELS.includes(model)) return res.status(400).json({ code: 'MODEL_NOT_ALLOWED' });
|
|
521
|
+
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
522
|
+
method: 'POST',
|
|
523
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': process.env.GEMINI_API_KEY },
|
|
524
|
+
body: JSON.stringify(req.body),
|
|
525
|
+
});
|
|
526
|
+
res.status(r.status).type('application/json').send(await r.text());
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**Billing (optional, on your server)** — the module's `points` / `freeCount` are only a display copy. If you charge, do it on the server: reserve before calling Gemini, confirm only when the response contains an image, refund otherwise (failures and safety blocks should be free). Then send the new balance back with `setAIConfig` (e.g. in `addAIUsageListener`).
|
|
531
|
+
|
|
532
|
+
**Errors** — any status other than 200 is a failed attempt (nothing is deducted in the module). To show your own reason, answer in Gemini's error shape: `{ "error": { "message": "…" } }`. The "Not enough points · Top up" prompt comes from the module's own copy of `points` / `freeCount`, so keep it in sync with `setAIConfig`.
|
|
533
|
+
|
|
534
|
+
**Security** — keep the key only on the server, allow-list models, limit the body size, and rate-limit per user (photo analysis requests carry no image output and are easy to abuse if left unlimited).
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
441
538
|
### Misc
|
|
442
539
|
|
|
443
540
|
| API | Description |
|
package/android/build.gradle
CHANGED
|
@@ -112,13 +112,27 @@ if (project != rootProject) {
|
|
|
112
112
|
// 첨부 메뉴 > 위치 — AAR 내부 의존성은 앱으로 전달되지 않으므로 여기에도 선언 (Glide 와 같은 이유)
|
|
113
113
|
implementation "com.google.android.gms:play-services-maps:19.0.0"
|
|
114
114
|
implementation "com.google.android.gms:play-services-location:21.3.0"
|
|
115
|
+
// AI 편집: 무료 기기 사진 분석 (얼굴·이미지 라벨). Play 서비스판이라 모델은 기기가 받아 둠 (APK 용량 거의 안 늘어남)
|
|
116
|
+
implementation "com.google.android.gms:play-services-mlkit-face-detection:17.1.0"
|
|
117
|
+
implementation "com.google.android.gms:play-services-mlkit-image-labeling:16.0.8"
|
|
115
118
|
|
|
119
|
+
// 동영상 (media3 Transformer) — AAR 내부 의존성은 앱으로 전달되지 않으므로 여기에도 선언
|
|
120
|
+
def media3_version = "1.4.1"
|
|
121
|
+
implementation "androidx.media3:media3-common:${media3_version}"
|
|
122
|
+
implementation "androidx.media3:media3-exoplayer:${media3_version}"
|
|
123
|
+
// 이미지 뷰어 동영상: HLS(.m3u8) 스트리밍 재생 (iOS AVPlayer 와 맞춤)
|
|
124
|
+
implementation "androidx.media3:media3-exoplayer-hls:${media3_version}"
|
|
125
|
+
implementation "androidx.media3:media3-ui:${media3_version}"
|
|
126
|
+
implementation "androidx.media3:media3-transformer:${media3_version}"
|
|
127
|
+
implementation "androidx.media3:media3-effect:${media3_version}"
|
|
128
|
+
|
|
116
129
|
// CameraX dependencies
|
|
117
130
|
def camerax_version = "1.3.1"
|
|
118
131
|
implementation "androidx.camera:camera-core:${camerax_version}"
|
|
119
132
|
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
|
120
133
|
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
|
121
134
|
implementation "androidx.camera:camera-view:${camerax_version}"
|
|
135
|
+
implementation "androidx.camera:camera-video:${camerax_version}" // 전체 화면 카메라 동영상 녹화
|
|
122
136
|
implementation "androidx.camera:camera-extensions:${camerax_version}"
|
|
123
137
|
}
|
|
124
138
|
}
|
|
Binary file
|
|
@@ -40,6 +40,33 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
40
40
|
private var maxWidth: Int = 1024
|
|
41
41
|
private var maxHeight: Int = 1024
|
|
42
42
|
private var outputFormat: String = "webp" // Default to webp
|
|
43
|
+
// ─── 동영상 (openGallery mediaType / video 옵션) ───
|
|
44
|
+
private var mediaType: String = "photo"
|
|
45
|
+
private var videoOptions = com.rnattachmentkit.VideoExportOptions()
|
|
46
|
+
/** onSelectionChange 에 실어 보낼 maxSelection (예전엔 10 고정) */
|
|
47
|
+
private var lastMaxSelection: Int = 10
|
|
48
|
+
private var lastThemeColorInt: Int? = null
|
|
49
|
+
private var lastLanguageCode: String = "en"
|
|
50
|
+
@Volatile private var currentVideoExporter: com.rnattachmentkit.VideoExporter? = null
|
|
51
|
+
@Volatile private var videoCancelled = false
|
|
52
|
+
/** 진행 중인 전송 처리 (processingId → 작업). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음 */
|
|
53
|
+
private val activeProcessingJobs = java.util.concurrent.ConcurrentHashMap<String, com.rnattachmentkit.ProcessingJob>()
|
|
54
|
+
/** 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때) */
|
|
55
|
+
@Volatile private var lastProcessingId: String? = null
|
|
56
|
+
|
|
57
|
+
/** 보낸 순간의 설정 — 처리 중에 갤러리를 다시 열면 모듈 필드가 새 호출 값으로 바뀌므로, 처리는 이 사본만 씀 */
|
|
58
|
+
private class SendSnapshot(
|
|
59
|
+
val maxWidth: Int, val maxHeight: Int, val outputFormat: String,
|
|
60
|
+
val videoOptions: com.rnattachmentkit.VideoExportOptions,
|
|
61
|
+
val states: Map<String, com.rnattachmentkit.VideoEditState>,
|
|
62
|
+
val showHUD: Boolean, val themeColor: Int?, val language: String
|
|
63
|
+
)
|
|
64
|
+
private fun sendSnapshot() = SendSnapshot(maxWidth, maxHeight, outputFormat, videoOptions, HashMap(lastVideoEditStates),
|
|
65
|
+
showVideoHUD, lastThemeColorInt, lastLanguageCode)
|
|
66
|
+
/** 동영상 변환 진행 HUD — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만 (이벤트로 말풍선에 그리는 앱은 HUD 없이) */
|
|
67
|
+
private var showVideoHUD = true
|
|
68
|
+
/** 시트가 닫힐 때 넘겨받은 동영상 편집 상태 (원본 uri 문자열 → 품질·음소거) */
|
|
69
|
+
private var lastVideoEditStates: Map<String, com.rnattachmentkit.VideoEditState> = emptyMap()
|
|
43
70
|
|
|
44
71
|
// newarch에는 이 맵과 getDefaultAnimationConfig() 구현이 통째로 빠져 있었습니다(oldarch에는 있었음).
|
|
45
72
|
// NativeAttachmentKitSpec(codegen 생성 추상 클래스)이 이 메서드를 abstract로 요구하기 때문에,
|
|
@@ -317,6 +344,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
317
344
|
false
|
|
318
345
|
}
|
|
319
346
|
|
|
347
|
+
// 동영상: mediaType "photo"(기본) | "video" | "all", video: VideoOptions
|
|
348
|
+
mediaType = (if (options.hasKey("mediaType") && !options.isNull("mediaType")) options.getString("mediaType") else null)
|
|
349
|
+
?.lowercase()?.takeIf { it == "video" || it == "all" } ?: "photo"
|
|
350
|
+
videoOptions = com.rnattachmentkit.VideoExportOptions.fromMap(
|
|
351
|
+
if (options.hasKey("video") && !options.isNull("video")) options.getMap("video")?.toHashMap() else null
|
|
352
|
+
)
|
|
353
|
+
run {
|
|
354
|
+
val async = options.hasKey("asyncProcessing") && !options.isNull("asyncProcessing") && options.getBoolean("asyncProcessing")
|
|
355
|
+
val v = if (options.hasKey("video") && !options.isNull("video")) options.getMap("video") else null
|
|
356
|
+
showVideoHUD = if (v != null && v.hasKey("showProgressHUD") && !v.isNull("showProgressHUD")) v.getBoolean("showProgressHUD") else !async
|
|
357
|
+
}
|
|
358
|
+
lastMaxSelection = maxSelection
|
|
359
|
+
TurboImagePickerConfig.videoOptions = videoOptions
|
|
360
|
+
lastLanguageCode = languageCode
|
|
361
|
+
lastThemeColorInt = try { themeColor?.let { android.graphics.Color.parseColor(it) } } catch (_: Exception) { null }
|
|
362
|
+
|
|
320
363
|
val fragmentActivity = activity as? FragmentActivity
|
|
321
364
|
if (fragmentActivity == null) {
|
|
322
365
|
promise.reject(E_NO_ACTIVITY, "Activity is not a FragmentActivity")
|
|
@@ -345,6 +388,12 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
345
388
|
} else {
|
|
346
389
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
347
390
|
}
|
|
391
|
+
// Android 13+ 에서 동영상도 보이면 READ_MEDIA_VIDEO 를 같이 요청
|
|
392
|
+
val mediaPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && mediaType != "photo") {
|
|
393
|
+
arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO)
|
|
394
|
+
} else {
|
|
395
|
+
arrayOf(permission)
|
|
396
|
+
}
|
|
348
397
|
|
|
349
398
|
val showSheet = {
|
|
350
399
|
fragmentActivity.runOnUiThread {
|
|
@@ -361,7 +410,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
361
410
|
options.getDouble("attachMenuMinBottomMargin").toFloat() else com.rnattachmentkit.AttachMenuBarView.BOTTOM_MARGIN_DP.toFloat(),
|
|
362
411
|
// 위치 보내기 때 지도 이미지 캡처 여부. 기본 true (기존 그대로). false 면 이미지 없이 위치 정보만
|
|
363
412
|
captureLocationImage = !(options.hasKey("captureLocationImage") && !options.isNull("captureLocationImage")) ||
|
|
364
|
-
options.getBoolean("captureLocationImage")
|
|
413
|
+
options.getBoolean("captureLocationImage"),
|
|
414
|
+
// 동영상 (mediaType "video"/"all" 일 때 그리드에 동영상, 길이 제한)
|
|
415
|
+
mediaType = mediaType,
|
|
416
|
+
maxVideoDurationSec = videoOptions.maxDurationSec
|
|
365
417
|
)
|
|
366
418
|
// 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
|
|
367
419
|
bottomSheet.setOnLocationSelectedListener { loc ->
|
|
@@ -407,6 +459,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
407
459
|
settle { it.resolve(com.facebook.react.bridge.Arguments.createArray()) }
|
|
408
460
|
}
|
|
409
461
|
bottomSheet.setOnImagesSelectedListener { uris ->
|
|
462
|
+
lastVideoEditStates = HashMap(bottomSheet.videoEditStates)
|
|
410
463
|
val result = WritableNativeArray()
|
|
411
464
|
for (uri in uris) {
|
|
412
465
|
result.pushString(uri.toString())
|
|
@@ -432,7 +485,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
432
485
|
}
|
|
433
486
|
}
|
|
434
487
|
|
|
435
|
-
if (ContextCompat.checkSelfPermission(fragmentActivity,
|
|
488
|
+
if (mediaPermissions.all { ContextCompat.checkSelfPermission(fragmentActivity, it) == PackageManager.PERMISSION_GRANTED }) {
|
|
436
489
|
showSheet()
|
|
437
490
|
} else {
|
|
438
491
|
val permissionAwareActivity = fragmentActivity as? PermissionAwareActivity
|
|
@@ -447,21 +500,19 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
447
500
|
showSheet()
|
|
448
501
|
return
|
|
449
502
|
}
|
|
450
|
-
|
|
503
|
+
mediaPermissions + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
|
|
451
504
|
} else {
|
|
452
|
-
|
|
505
|
+
mediaPermissions
|
|
453
506
|
}
|
|
454
507
|
|
|
455
508
|
permissionAwareActivity.requestPermissions(permissionsToRequest, 100, object : PermissionListener {
|
|
456
509
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Boolean {
|
|
457
510
|
if (requestCode == 100 && grantResults != null) {
|
|
458
511
|
val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
(grantResults.size > 1 && grantResults[1] == PackageManager.PERMISSION_GRANTED)
|
|
462
|
-
)
|
|
512
|
+
// 14+: 전체 허용이든 "일부 선택"이든 하나라도 허용이면 진행
|
|
513
|
+
grantResults.any { it == PackageManager.PERMISSION_GRANTED }
|
|
463
514
|
} else {
|
|
464
|
-
grantResults.isNotEmpty() && grantResults
|
|
515
|
+
grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
|
465
516
|
}
|
|
466
517
|
|
|
467
518
|
if (isGranted) {
|
|
@@ -600,6 +651,22 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
600
651
|
putStringArrayList("placeholderImages", placeholders)
|
|
601
652
|
}
|
|
602
653
|
}
|
|
654
|
+
// 동영상 페이지 (뷰어 동영상 작업 지시서 V1): videos[i] = { uri, cacheFile? } 또는 null(사진).
|
|
655
|
+
// images[i] 는 그 동영상의 포스터. 이 오버레이 뷰어에서만 재생합니다.
|
|
656
|
+
if (options.hasKey("videos")) {
|
|
657
|
+
val videoArray = options.getArray("videos")
|
|
658
|
+
if (videoArray != null) {
|
|
659
|
+
val uris = ArrayList<String>()
|
|
660
|
+
val caches = ArrayList<String>()
|
|
661
|
+
for (i in 0 until videoArray.size()) {
|
|
662
|
+
val m = if (videoArray.getType(i) == com.facebook.react.bridge.ReadableType.Map) videoArray.getMap(i) else null
|
|
663
|
+
uris.add(if (m != null && m.hasKey("uri") && !m.isNull("uri")) m.getString("uri") ?: "" else "")
|
|
664
|
+
caches.add(if (m != null && m.hasKey("cacheFile") && !m.isNull("cacheFile")) m.getString("cacheFile") ?: "" else "")
|
|
665
|
+
}
|
|
666
|
+
putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_URIS, uris)
|
|
667
|
+
putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_CACHE_FILES, caches)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
603
670
|
// 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
|
|
604
671
|
// 네이티브 쪽 로케일 리소스(values-ko/ja/zh, 그 외 en)가 자동 적용됩니다.
|
|
605
672
|
putBoolean("showActionButtons", !options.hasKey("showActionButtons") || options.getBoolean("showActionButtons"))
|
|
@@ -761,6 +828,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
761
828
|
}
|
|
762
829
|
}
|
|
763
830
|
|
|
831
|
+
/** 동영상 변환 취소 (HUD 없이 앱이 말풍선에서). index -1 = 남은 동영상 전부. 진행 중인 처리가 없으면 false */
|
|
832
|
+
override fun cancelVideoProcessing(index: Double, processingId: String, promise: Promise) {
|
|
833
|
+
val job = (processingId.ifEmpty { lastProcessingId })?.let { activeProcessingJobs[it] }
|
|
834
|
+
if (job == null) { promise.resolve(false); return }
|
|
835
|
+
job.cancel(if (index >= 0) index.toInt() else null)
|
|
836
|
+
promise.resolve(true)
|
|
837
|
+
}
|
|
838
|
+
|
|
764
839
|
override fun closeGallery(promise: Promise) {
|
|
765
840
|
val activity = getCurrentActivity()
|
|
766
841
|
if (activity == null) {
|
|
@@ -809,9 +884,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
809
884
|
return
|
|
810
885
|
}
|
|
811
886
|
|
|
887
|
+
val snap = sendSnapshot()
|
|
812
888
|
moduleScope.launch {
|
|
813
889
|
try {
|
|
814
|
-
val results = processImages(uris)
|
|
890
|
+
val results = processImages(uris, snap)
|
|
815
891
|
if (results.isNotEmpty()) {
|
|
816
892
|
promise.resolve(results[0])
|
|
817
893
|
} else {
|
|
@@ -855,7 +931,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
855
931
|
// Process images asynchronously
|
|
856
932
|
val eventMap = com.facebook.react.bridge.WritableNativeMap()
|
|
857
933
|
eventMap.putInt("selectedCount", uris.size)
|
|
858
|
-
eventMap.putInt("maxSelection",
|
|
934
|
+
eventMap.putInt("maxSelection", lastMaxSelection)
|
|
859
935
|
try {
|
|
860
936
|
reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
861
937
|
.emit("onSelectionChange", eventMap)
|
|
@@ -863,25 +939,183 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
863
939
|
// Ignore
|
|
864
940
|
}
|
|
865
941
|
|
|
942
|
+
val snap = sendSnapshot()
|
|
866
943
|
moduleScope.launch {
|
|
867
944
|
try {
|
|
868
|
-
val results = processImages(uris)
|
|
945
|
+
val results = processImages(uris, snap)
|
|
869
946
|
val array = com.facebook.react.bridge.Arguments.createArray()
|
|
870
947
|
results.forEach { array.pushMap(it) }
|
|
871
948
|
actualPromise.resolve(array)
|
|
949
|
+
} catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
|
|
950
|
+
actualPromise.reject("CANCELLED", "Video export cancelled")
|
|
872
951
|
} catch (e: Exception) {
|
|
873
952
|
actualPromise.reject(E_FAILED_TO_PICK, "Failed to process images: ${e.message}", e)
|
|
874
953
|
}
|
|
875
954
|
}
|
|
876
955
|
}
|
|
877
956
|
|
|
878
|
-
private suspend fun processImages(uris: List<Uri
|
|
957
|
+
private suspend fun processImages(uris: List<Uri>, snap: SendSnapshot): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
|
|
958
|
+
// 보낸 순간의 설정 (모듈 필드와 같은 이름으로 가림 — 아래 코드는 그대로)
|
|
959
|
+
val maxWidth = snap.maxWidth
|
|
960
|
+
val maxHeight = snap.maxHeight
|
|
961
|
+
val outputFormat = snap.outputFormat
|
|
962
|
+
val videoOptions = snap.videoOptions
|
|
963
|
+
val lastVideoEditStates = snap.states
|
|
964
|
+
val showVideoHUD = snap.showHUD
|
|
965
|
+
val lastThemeColorInt = snap.themeColor
|
|
966
|
+
val lastLanguageCode = snap.language
|
|
967
|
+
val processingId = java.util.UUID.randomUUID().toString()
|
|
879
968
|
val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
|
|
880
969
|
val contentResolver = reactApplicationContext.contentResolver
|
|
881
970
|
val totalCount = uris.size
|
|
882
971
|
var currentIndex = 0
|
|
883
972
|
|
|
973
|
+
// 동영상이 섞여 있으면 진행 HUD 를 띄우고, 동영상은 VideoExporter 로 (사진 처리와 같은 루프)
|
|
974
|
+
val isVideoUri: (Uri) -> Boolean = { u ->
|
|
975
|
+
u.toString().contains("/video/") || (try { contentResolver.getType(u)?.startsWith("video/") == true } catch (_: Exception) { false })
|
|
976
|
+
}
|
|
977
|
+
val hasVideo = uris.any(isVideoUri)
|
|
978
|
+
videoCancelled = false
|
|
979
|
+
val job = com.rnattachmentkit.ProcessingJob()
|
|
980
|
+
activeProcessingJobs[processingId] = job
|
|
981
|
+
lastProcessingId = processingId
|
|
982
|
+
// ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
|
|
983
|
+
try {
|
|
984
|
+
val items = Arguments.createArray()
|
|
985
|
+
uris.forEachIndexed { i, u ->
|
|
986
|
+
val it0 = if (isVideoUri(u)) {
|
|
987
|
+
val st = lastVideoEditStates[u.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
|
|
988
|
+
com.rnattachmentkit.MediaProcessing.videoItem(reactApplicationContext, i, u, st, videoOptions)
|
|
989
|
+
} else {
|
|
990
|
+
com.rnattachmentkit.MediaProcessing.imageItem(reactApplicationContext, i, u, maxWidth, maxHeight)
|
|
991
|
+
}
|
|
992
|
+
items.pushMap(Arguments.createMap().apply {
|
|
993
|
+
putInt("index", it0.index)
|
|
994
|
+
putString("mediaType", if (it0.isVideo) "video" else "image")
|
|
995
|
+
putString("originalUri", it0.originalUri)
|
|
996
|
+
putInt("width", it0.width)
|
|
997
|
+
putInt("height", it0.height)
|
|
998
|
+
it0.durationSec?.let { d -> putDouble("duration", d) }
|
|
999
|
+
it0.thumbnailUri?.let { t ->
|
|
1000
|
+
putString("thumbnailUri", t)
|
|
1001
|
+
putInt("thumbnailWidth", it0.thumbnailWidth)
|
|
1002
|
+
putInt("thumbnailHeight", it0.thumbnailHeight)
|
|
1003
|
+
}
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
|
|
1007
|
+
putString("processingId", processingId)
|
|
1008
|
+
putInt("total", totalCount)
|
|
1009
|
+
putArray("items", items)
|
|
1010
|
+
})
|
|
1011
|
+
} catch (e: Exception) {
|
|
1012
|
+
android.util.Log.w("RNAttachmentKit", "onProcessingStart failed: ${e.message}")
|
|
1013
|
+
}
|
|
1014
|
+
var hud: com.rnattachmentkit.VideoExportHUD? = null
|
|
1015
|
+
if (hasVideo && showVideoHUD) {
|
|
1016
|
+
withContext(Dispatchers.Main) {
|
|
1017
|
+
val act = getCurrentActivity() ?: return@withContext
|
|
1018
|
+
hud = com.rnattachmentkit.VideoExportHUD(act, lastThemeColorInt, lastLanguageCode).also { h ->
|
|
1019
|
+
h.onCancel = {
|
|
1020
|
+
videoCancelled = true
|
|
1021
|
+
currentVideoExporter?.cancel()
|
|
1022
|
+
}
|
|
1023
|
+
h.show()
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
val videoFiles = mutableListOf<File>()
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
884
1030
|
for (uri in uris) {
|
|
1031
|
+
if (videoCancelled) throw com.rnattachmentkit.VideoExporter.CancelledException()
|
|
1032
|
+
if (isVideoUri(uri)) {
|
|
1033
|
+
val idx = currentIndex
|
|
1034
|
+
val exporter = com.rnattachmentkit.VideoExporter(reactApplicationContext)
|
|
1035
|
+
currentVideoExporter = exporter
|
|
1036
|
+
val cancelledMap = {
|
|
1037
|
+
WritableNativeMap().apply {
|
|
1038
|
+
putString("originalUri", uri.toString())
|
|
1039
|
+
putString("mediaType", "video")
|
|
1040
|
+
putString("error", "CANCELLED")
|
|
1041
|
+
putBoolean("cancelled", true)
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
val resultMap = try {
|
|
1045
|
+
// 앱이 이미 취소한 동영상은 건너뜀
|
|
1046
|
+
if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
|
|
1047
|
+
job.begin(idx, exporter)
|
|
1048
|
+
val state = lastVideoEditStates[uri.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
|
|
1049
|
+
val throttle = com.rnattachmentkit.ProgressThrottle()
|
|
1050
|
+
val onProgress: (Float) -> Unit = { p ->
|
|
1051
|
+
hud?.let { h -> android.os.Handler(android.os.Looper.getMainLooper()).post { h.update(p, idx, totalCount) } }
|
|
1052
|
+
if (throttle.shouldSend(p)) emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
|
|
1053
|
+
putInt("index", idx); putInt("total", totalCount); putDouble("progress", p.toDouble())
|
|
1054
|
+
putString("processingId", processingId)
|
|
1055
|
+
})
|
|
1056
|
+
}
|
|
1057
|
+
// 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
|
|
1058
|
+
// 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
|
|
1059
|
+
val stop = { videoCancelled || job.isCancelled(idx) }
|
|
1060
|
+
val res = com.rnattachmentkit.VideoPrepareManager.takeReady(uri, state, onProgress, stop)
|
|
1061
|
+
?: run {
|
|
1062
|
+
if (stop()) throw com.rnattachmentkit.VideoExporter.CancelledException()
|
|
1063
|
+
exporter.export(uri, state, videoOptions, reactApplicationContext.cacheDir, "turbo_video_${System.currentTimeMillis()}_$idx", onProgress)
|
|
1064
|
+
}
|
|
1065
|
+
withContext(Dispatchers.Main) { hud?.update(1f, idx, totalCount) }
|
|
1066
|
+
// 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
|
|
1067
|
+
if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
|
|
1068
|
+
videoFiles += res.file
|
|
1069
|
+
res.thumbnailFile?.let { videoFiles += it }
|
|
1070
|
+
WritableNativeMap().apply {
|
|
1071
|
+
putString("uri", Uri.fromFile(res.file).toString())
|
|
1072
|
+
putString("originalUri", uri.toString())
|
|
1073
|
+
putString("mediaType", "video")
|
|
1074
|
+
putString("type", "video/mp4")
|
|
1075
|
+
putInt("width", res.width)
|
|
1076
|
+
putInt("height", res.height)
|
|
1077
|
+
putDouble("duration", res.durationMs / 1000.0)
|
|
1078
|
+
putDouble("fileSize", res.fileSize.toDouble())
|
|
1079
|
+
putString("fileName", res.file.name)
|
|
1080
|
+
putString("fileExtension", "mp4")
|
|
1081
|
+
putString("videoCodec", res.codec)
|
|
1082
|
+
putInt("videoBitrate", res.bitrate)
|
|
1083
|
+
putBoolean("edited", res.edited)
|
|
1084
|
+
if (res.thumbnailFile != null) {
|
|
1085
|
+
putString("thumbnailUri", Uri.fromFile(res.thumbnailFile).toString())
|
|
1086
|
+
putInt("thumbnailWidth", res.thumbnailWidth)
|
|
1087
|
+
putInt("thumbnailHeight", res.thumbnailHeight)
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
} catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
|
|
1091
|
+
// 앱이 이 동영상만 취소 → 나머지는 계속. HUD 취소면 전체 취소
|
|
1092
|
+
if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else throw e
|
|
1093
|
+
} catch (e: Exception) {
|
|
1094
|
+
// 취소하면서 변환기가 다른 예외로 끝날 수도 있음 → 취소로 처리
|
|
1095
|
+
if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else {
|
|
1096
|
+
android.util.Log.w("RNAttachmentKit", "video export failed: ${e.message}", e)
|
|
1097
|
+
WritableNativeMap().apply {
|
|
1098
|
+
putString("originalUri", uri.toString())
|
|
1099
|
+
putString("mediaType", "video")
|
|
1100
|
+
putString("error", e.message ?: "export failed")
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
} finally {
|
|
1104
|
+
currentVideoExporter = null
|
|
1105
|
+
job.end()
|
|
1106
|
+
}
|
|
1107
|
+
results.add(resultMap)
|
|
1108
|
+
try {
|
|
1109
|
+
val copy = WritableNativeMap().apply { merge(resultMap) }
|
|
1110
|
+
reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
1111
|
+
.emit("onImageProcessed", WritableNativeMap().apply {
|
|
1112
|
+
putInt("total", totalCount); putInt("index", idx); putMap("image", copy)
|
|
1113
|
+
putString("processingId", processingId)
|
|
1114
|
+
})
|
|
1115
|
+
} catch (_: Exception) {}
|
|
1116
|
+
currentIndex++
|
|
1117
|
+
continue
|
|
1118
|
+
}
|
|
885
1119
|
try {
|
|
886
1120
|
// 1. Get EXIF Orientation
|
|
887
1121
|
var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
|
|
@@ -1012,6 +1246,7 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
1012
1246
|
val eventMap = WritableNativeMap().apply {
|
|
1013
1247
|
putInt("total", totalCount)
|
|
1014
1248
|
putInt("index", currentIndex)
|
|
1249
|
+
putString("processingId", processingId)
|
|
1015
1250
|
putMap("image", createImageInfo())
|
|
1016
1251
|
}
|
|
1017
1252
|
reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
@@ -1027,6 +1262,14 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
1027
1262
|
continue
|
|
1028
1263
|
}
|
|
1029
1264
|
}
|
|
1265
|
+
} catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
|
|
1266
|
+
// 취소: 지금까지 만든 동영상 파일은 지움
|
|
1267
|
+
videoFiles.forEach { try { it.delete() } catch (_: Exception) {} }
|
|
1268
|
+
throw e
|
|
1269
|
+
} finally {
|
|
1270
|
+
activeProcessingJobs.remove(processingId)
|
|
1271
|
+
if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
|
|
1272
|
+
}
|
|
1030
1273
|
|
|
1031
1274
|
results
|
|
1032
1275
|
}
|
|
@@ -1305,6 +1548,13 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
1305
1548
|
)
|
|
1306
1549
|
if (rect != null) f.sourceRectProvider = { mapViewerSourceRect }
|
|
1307
1550
|
var settled = false
|
|
1551
|
+
f.onWillClose = { ms ->
|
|
1552
|
+
try {
|
|
1553
|
+
reactApplicationContext
|
|
1554
|
+
.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
1555
|
+
.emit("onMapViewerWillClose", Arguments.createMap().apply { putInt("durationMs", ms) })
|
|
1556
|
+
} catch (_: Exception) {}
|
|
1557
|
+
}
|
|
1308
1558
|
f.onClosed = {
|
|
1309
1559
|
if (!settled) { settled = true; promise.resolve(null) }
|
|
1310
1560
|
mapViewerSourceRect = null
|
|
@@ -1432,6 +1682,23 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
1432
1682
|
}
|
|
1433
1683
|
}
|
|
1434
1684
|
|
|
1685
|
+
// ── AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림 ──
|
|
1686
|
+
|
|
1687
|
+
override fun setAIConfig(config: ReadableMap, promise: Promise) {
|
|
1688
|
+
AIBilling.apply(config.toHashMap())
|
|
1689
|
+
val ctx = reactApplicationContext
|
|
1690
|
+
fun emit(name: String, body: Map<String, Any>) {
|
|
1691
|
+
// 앱이 내려가는 중이면 조용히 무시
|
|
1692
|
+
runCatching {
|
|
1693
|
+
ctx.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
1694
|
+
.emit(name, com.facebook.react.bridge.Arguments.makeNativeMap(body))
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
AIBilling.onUsage = { emit("onAIUsage", it) }
|
|
1698
|
+
AIBilling.onChargeRequired = { emit("onAIChargeRequired", it) }
|
|
1699
|
+
promise.resolve(null)
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1435
1702
|
// ── 역지오코딩 (기기 Geocoder, 디스크 캐시) ──
|
|
1436
1703
|
|
|
1437
1704
|
override fun reverseGeocode(options: ReadableMap, promise: Promise) {
|