capacitor-native-video-compressor 1.2.0 → 2.0.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.
package/README.md CHANGED
@@ -229,9 +229,9 @@ The `quality` parameter determines the output resolution and compression ratio.
229
229
 
230
230
  * [`compressVideo(...)`](#compressvideo)
231
231
  * [`cancel(...)`](#cancel)
232
+ * [`finishUpload(...)`](#finishupload)
232
233
  * [`initialize()`](#initialize)
233
234
  * [`addListener('onProgress', ...)`](#addlisteneronprogress-)
234
- * [`addListener('onCancelRequested', ...)`](#addlisteneroncancelrequested-)
235
235
  * [Interfaces](#interfaces)
236
236
 
237
237
  </docgen-index>
@@ -245,6 +245,16 @@ The `quality` parameter determines the output resolution and compression ratio.
245
245
  compressVideo(options: CompressOptions) => Promise<CompressResult>
246
246
  ```
247
247
 
248
+ Compress one video and resolve with the path of the result.
249
+
250
+ Android: the work runs inside a WorkManager worker, which keeps the
251
+ process out of the frozen cached state while the user is away — there is
252
+ no foreground service and no notification, because Google Play classes
253
+ this use case as data sync plus local processing and rejects
254
+ `FOREGROUND_SERVICE_MEDIA_PROCESSING` for it. WorkManager stops a worker
255
+ after 10 minutes, so a compress that runs longer rejects with
256
+ {@link CANCELLED}.
257
+
248
258
  | Param | Type |
249
259
  | ------------- | ----------------------------------------------------------- |
250
260
  | **`options`** | <code><a href="#compressoptions">CompressOptions</a></code> |
@@ -276,6 +286,28 @@ thread (native), and none of them watch an AbortSignal.
276
286
  --------------------
277
287
 
278
288
 
289
+ ### finishUpload(...)
290
+
291
+ ```typescript
292
+ finishUpload(options: { jobId: string; }) => Promise<{ released: boolean; }>
293
+ ```
294
+
295
+ Release the worker that `holdForUpload` kept alive for this job.
296
+
297
+ Call it on every terminal branch of the upload, success or failure. A
298
+ missed call leaves the device feeding an idle job until the hold expires.
299
+ `released` is false when no job by that id was still being held, which is
300
+ the normal outcome of a late or duplicate call, not an error.
301
+
302
+ | Param | Type |
303
+ | ------------- | ------------------------------- |
304
+ | **`options`** | <code>{ jobId: string; }</code> |
305
+
306
+ **Returns:** <code>Promise&lt;{ released: boolean; }&gt;</code>
307
+
308
+ --------------------
309
+
310
+
279
311
  ### initialize()
280
312
 
281
313
  ```typescript
@@ -303,29 +335,6 @@ addListener(eventName: 'onProgress', listenerFunc: (info: { status: string; perc
303
335
  --------------------
304
336
 
305
337
 
306
- ### addListener('onCancelRequested', ...)
307
-
308
- ```typescript
309
- addListener(eventName: 'onCancelRequested', listenerFunc: () => void) => Promise<PluginListenerHandle>
310
- ```
311
-
312
- Android: user đã bấm "Huỷ" trên notification của foreground service.
313
-
314
- Pha nén được plugin tự huỷ trước khi event này bắn, nhưng việc caller định
315
- làm với kết quả nén - upload chẳng hạn - thì không ai dừng hộ được, nên
316
- caller phải tự abort. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ,
317
- và Play đánh trượt tiêu chí "can be terminated or stopped by the user".
318
-
319
- | Param | Type |
320
- | ------------------ | -------------------------------- |
321
- | **`eventName`** | <code>'onCancelRequested'</code> |
322
- | **`listenerFunc`** | <code>() =&gt; void</code> |
323
-
324
- **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
325
-
326
- --------------------
327
-
328
-
329
338
  ### Interfaces
330
339
 
331
340
 
@@ -339,12 +348,13 @@ và Play đánh trượt tiêu chí "can be terminated or stopped by the user".
339
348
 
340
349
  #### CompressOptions
341
350
 
342
- | Prop | Type | Description |
343
- | ---------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
344
- | **`sourcePath`** | <code>string</code> | |
345
- | **`destPath`** | <code>string</code> | |
346
- | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
347
- | **`jobId`** | <code>string</code> | Names this compress so `cancel({ jobId })` can target it. Every platform compresses one video at a time, so cancellation is global by nature. Two independent callers in the same app (say a comment queue and a notes queue) therefore need a way to say *which* job they mean — without one, a cancel from either kills whatever the other is running. |
351
+ | Prop | Type | Description |
352
+ | ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
353
+ | **`sourcePath`** | <code>string</code> | |
354
+ | **`destPath`** | <code>string</code> | |
355
+ | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
356
+ | **`jobId`** | <code>string</code> | Names this compress so `cancel({ jobId })` can target it. Every platform compresses one video at a time, so cancellation is global by nature. Two independent callers in the same app (say a comment queue and a notes queue) therefore need a way to say *which* job they mean — without one, a cancel from either kills whatever the other is running. |
357
+ | **`holdForUpload`** | <code>boolean</code> | Android only: keep the background worker alive after the compress ends, until `finishUpload({ jobId })` is called. The upload runs as JavaScript in the webview, and the moment the worker returns the process becomes cached and is frozen — measured on Android 16, an in-flight upload dies about five seconds later. Set this whenever the compressed file is about to be uploaded, and always pair it with a `finishUpload` in a `finally`. |
348
358
 
349
359
 
350
360
  #### CancelResult
@@ -3,6 +3,7 @@ ext {
3
3
  androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
4
4
  androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
5
5
  androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
6
+ androidxWorkVersion = project.hasProperty('androidxWorkVersion') ? rootProject.ext.androidxWorkVersion : '2.10.0'
6
7
  }
7
8
 
8
9
  buildscript {
@@ -21,10 +22,9 @@ android {
21
22
  namespace = "com.atomic.videocompressor"
22
23
  compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
23
24
  defaultConfig {
24
- // 35, không phải 24: foreground service của pha nén khai mediaProcessing,
25
- // type chỉ tồn tại từ API 35. Dưới mức đó hệ thống đọc ra "type unknown"
26
- // và startForeground() ném InvalidForegroundServiceTypeException.
27
- minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 35
25
+ // 24 trở lại: mức 35 trước đây chỉ để đáp ứng foreground service type
26
+ // mediaProcessing, thứ đã gỡ hẳn. WorkManager chạy từ API 21.
27
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
28
28
  targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
29
29
  versionCode 1
30
30
  versionName "1.0"
@@ -60,4 +60,6 @@ dependencies {
60
60
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
61
61
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
62
62
  implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
63
+ // Giữ process khỏi bị đóng băng trong lúc nén, thay cho foreground service.
64
+ implementation "androidx.work:work-runtime:$androidxWorkVersion"
63
65
  }
@@ -1,20 +1,11 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
3
- <!-- Chỉ mediaProcessing: service sống đúng bằng pha nén, là use case duy
4
- nhất ở đây thực sự chạy khi user rời app. Không khai dataSync nữa, vì
5
- pha upload chạy bằng JS trong webview nên nó đứng lại ngay khi app
6
- xuống nền - giữ foreground service cho nó là khai một việc không diễn
7
- ra, và Play xét FGS theo use case chứ không theo số service.
2
+ <!-- Cố ý rỗng. Google Play xếp "nén rồi upload video do user chọn" vào data
3
+ sync + local processing chứ không phải media transcoding, nên
4
+ FOREGROUND_SERVICE_MEDIA_PROCESSING bị từ chối ở version code 32 kèm
5
+ yêu cầu chuyển sang WorkManager.
8
6
 
9
- mediaProcessing chỉ có từ API 35, và ở đây không còn type nào để hạ
10
- xuống, nên minSdk của plugin là 35 (xem android/build.gradle). -->
11
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" />
12
- <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
13
-
14
- <application>
15
- <service
16
- android:name="com.atomic.videocompressor.VideoCompressionService"
17
- android:foregroundServiceType="mediaProcessing"
18
- android:exported="false" />
19
- </application>
7
+ Pha nén giờ chạy trong VideoCompressionWorker, thứ WorkManager tự
8
+ quản lý - không service nào để khai, và mọi permission plugin từng
9
+ thêm vào app (FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PROCESSING,
10
+ POST_NOTIFICATIONS) đều đã gỡ. Đừng thêm lại. -->
20
11
  </manifest>
@@ -1,10 +1,13 @@
1
1
  package com.atomic.videocompressor;
2
2
 
3
- import android.content.Intent;
4
3
  import android.net.Uri;
5
4
  import android.util.Log;
6
5
  import androidx.annotation.NonNull;
7
6
  import androidx.annotation.Nullable;
7
+ import androidx.work.Data;
8
+ import androidx.work.ExistingWorkPolicy;
9
+ import androidx.work.OneTimeWorkRequest;
10
+ import androidx.work.WorkManager;
8
11
  import com.abedelazizshe.lightcompressorlibrary.CompressionListener;
9
12
  import com.abedelazizshe.lightcompressorlibrary.VideoCompressor;
10
13
  import com.abedelazizshe.lightcompressorlibrary.VideoQuality;
@@ -16,13 +19,17 @@ import com.getcapacitor.PluginCall;
16
19
  import com.getcapacitor.PluginMethod;
17
20
  import com.getcapacitor.annotation.CapacitorPlugin;
18
21
  import java.io.File;
19
- import java.lang.ref.WeakReference;
20
22
  import java.util.Arrays;
21
23
  import java.util.Collections;
24
+ import java.util.concurrent.CountDownLatch;
25
+ import java.util.concurrent.TimeUnit;
26
+ import java.util.concurrent.atomic.AtomicBoolean;
22
27
 
23
28
  @CapacitorPlugin(name = "NativeVideoCompressor")
24
29
  public class NativeVideoCompressorPlugin extends Plugin {
25
30
 
31
+ private static final String TAG = "NativeVideoCompressor";
32
+
26
33
  /**
27
34
  * Matches the `CANCELLED` constant the JS side exports, so one
28
35
  * `error.code` check covers web, iOS and Android alike.
@@ -32,51 +39,14 @@ public class NativeVideoCompressorPlugin extends Plugin {
32
39
  private static final String STORAGE_SUBFOLDER = "compressed_videos";
33
40
 
34
41
  /**
35
- * Instance đang sống, để service gọi ngược khi user bấm "Huỷ" trên
36
- * notification. WeakReference vì service sống lâu hơn bridge: giữ cứng ở
37
- * đây là giữ luôn cả WebView sau khi Activity đã chết.
42
+ * Trần cho pha giữ-chờ-upload. Ngắn hơn mốc 10 phút mà WorkManager tự cắt,
43
+ * để một caller chết giữa chừng không chiếm quota job tới tận phút cuối.
38
44
  */
39
- private static WeakReference<NativeVideoCompressorPlugin> active = new WeakReference<>(null);
45
+ private static final long HOLD_FOR_UPLOAD_TIMEOUT_MINUTES = 8;
40
46
 
41
47
  /** The job compressVideo is running right now, for cancel({ jobId }). */
42
48
  private String currentJobId = null;
43
49
 
44
- @Override
45
- public void load() {
46
- super.load();
47
- active = new WeakReference<>(this);
48
- }
49
-
50
- @Override
51
- protected void handleOnDestroy() {
52
- if (active.get() == this) active = new WeakReference<>(null);
53
- super.handleOnDestroy();
54
- }
55
-
56
- /**
57
- * User bấm "Huỷ" trên notification. Phải huỷ được cả hai pha:
58
- *
59
- * - pha nén chạy trong LightCompressor (native) -> cancel() tĩnh, gọi được
60
- * kể cả khi bridge đã chết
61
- * - pha upload chạy trong JS -> chỉ có thể nhờ JS tự dừng, nên bắn
62
- * onCancelRequested; không có event này thì bấm Huỷ lúc đang upload chỉ
63
- * tắt được notification còn request vẫn chạy tiếp, và Play coi như
64
- * "không dừng được bởi user".
65
- */
66
- static void cancelAllFromNotification() {
67
- try {
68
- VideoCompressor.cancel();
69
- } catch (Exception e) {
70
- Log.w("NativeVideoCompressor", "Could not cancel compression", e);
71
- }
72
- NativeVideoCompressorPlugin plugin = active.get();
73
- if (plugin == null) return;
74
- synchronized (plugin) {
75
- plugin.currentJobId = null;
76
- }
77
- plugin.notifyListeners("onCancelRequested", new JSObject());
78
- }
79
-
80
50
  /**
81
51
  * Nothing to do natively; LightCompressor needs no warm-up. Answered
82
52
  * rather than left unimplemented so a caller can await it unconditionally
@@ -104,11 +74,15 @@ public class NativeVideoCompressorPlugin extends Plugin {
104
74
  // running job could finish and a new one start in the gap — and
105
75
  // VideoCompressor.cancel() being global, it would kill the newcomer.
106
76
  // That is exactly what checking the jobId is meant to prevent.
107
- // The call is non-blocking (it cancels a coroutine job), so holding
108
- // the lock across it costs nothing.
77
+ // Both calls are non-blocking (one cancels a coroutine job, the other
78
+ // returns an Operation), so holding the lock across them costs nothing.
109
79
  synchronized (this) {
110
80
  cancelled = currentJobId != null && (requested == null || requested.equals(currentJobId));
111
81
  if (cancelled) {
82
+ // Huỷ cả work: pha nén có thể chưa kịp bắt đầu, lúc đó
83
+ // VideoCompressor.cancel() không có gì để huỷ và work vẫn sẽ
84
+ // chạy sau đó. Huỷ work làm onStopped bắn và job tự kết thúc.
85
+ cancelWork(currentJobId);
112
86
  VideoCompressor.cancel();
113
87
  }
114
88
  }
@@ -117,6 +91,21 @@ public class NativeVideoCompressorPlugin extends Plugin {
117
91
  call.resolve(ret);
118
92
  }
119
93
 
94
+ /**
95
+ * Caller báo đã xong việc với kết quả nén - thường là upload xong - nên
96
+ * worker đang giữ process sống được phép kết thúc.
97
+ *
98
+ * Phải gọi trên MỌI nhánh kết thúc của pha upload, kể cả nhánh lỗi. Bỏ sót
99
+ * một nhánh nghĩa là máy phải nuôi một job rỗng tới khi hết hạn giữ.
100
+ */
101
+ @PluginMethod
102
+ public void finishUpload(PluginCall call) {
103
+ String jobId = call.getString("jobId");
104
+ JSObject ret = new JSObject();
105
+ ret.put("released", jobId != null && VideoCompressionWorker.release(jobId));
106
+ call.resolve(ret);
107
+ }
108
+
120
109
  @PluginMethod
121
110
  public void compressVideo(PluginCall call) {
122
111
  String sourcePath = call.getString("sourcePath");
@@ -127,9 +116,6 @@ public class NativeVideoCompressorPlugin extends Plugin {
127
116
  }
128
117
 
129
118
  String jobId = call.getString("jobId", "default");
130
- synchronized (this) {
131
- currentJobId = jobId;
132
- }
133
119
 
134
120
  // 1. Chuẩn bị Uri và Tên file xuất ra
135
121
  Uri srcUri;
@@ -161,10 +147,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
161
147
  }
162
148
  }
163
149
 
164
- Log.d(
165
- "NativeVideoCompressor",
166
- "====== VIDEO COMPRESSION QUALITY RECEIVED: " + qualityString + "+ video quality: " + videoQuality + " ======"
167
- );
150
+ Log.d(TAG, "====== VIDEO COMPRESSION QUALITY RECEIVED: " + qualityString + "+ video quality: " + videoQuality + " ======");
168
151
 
169
152
  // 2. Cấu hình thông số nén (Dành riêng cho LightCompressor 1.3.2)
170
153
  // Lưu ý: Java phải truyền đủ 9 tham số do thư viện gốc viết bằng Kotlin
@@ -182,38 +165,165 @@ public class NativeVideoCompressorPlugin extends Plugin {
182
165
  // 3. Cấu hình nơi lưu (Lưu an toàn vào thư mục nội bộ của App)
183
166
  AppSpecificStorageConfiguration storageConfig = new AppSpecificStorageConfiguration(STORAGE_SUBFOLDER);
184
167
 
185
- // Khởi động Foreground Service để giữ app luôn sống khi chạy ngầm
186
- Intent serviceIntent = new Intent(getContext(), VideoCompressionService.class);
187
- serviceIntent.setAction(VideoCompressionService.ACTION_START);
188
- serviceIntent.putExtra(VideoCompressionService.EXTRA_JOB_ID, jobId);
168
+ synchronized (this) {
169
+ currentJobId = jobId;
170
+ }
171
+
172
+ // 4. Giao pha nén cho WorkManager. Không nén thẳng ở đây: một thread
173
+ // thường bị đóng băng cùng process ngay khi user rời app, còn process
174
+ // đang chạy worker thì không. Đây là phương án Google Play chỉ đích
175
+ // danh khi từ chối foreground service ở version code 32.
176
+ boolean holdForUpload = Boolean.TRUE.equals(call.getBoolean("holdForUpload", false));
177
+ CompressionJob job = new CompressionJob(jobId, srcUri, fileName, configuration, storageConfig, call, holdForUpload);
178
+ VideoCompressionWorker.register(jobId, job);
179
+
180
+ OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(VideoCompressionWorker.class)
181
+ .setInputData(new Data.Builder().putString(VideoCompressionWorker.KEY_JOB_ID, jobId).build())
182
+ .build();
183
+
189
184
  try {
190
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
191
- getContext().startForegroundService(serviceIntent);
185
+ WorkManager.getInstance(getContext()).enqueueUniqueWork(workName(jobId), ExistingWorkPolicy.REPLACE, request);
186
+ } catch (Exception e) {
187
+ VideoCompressionWorker.unregister(jobId);
188
+ synchronized (this) {
189
+ currentJobId = null;
190
+ }
191
+ call.reject("Cannot schedule video compression", e);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Một lần nén, chạy bên trong {@link VideoCompressionWorker}.
197
+ *
198
+ * Không phải static: nó cần notifyListeners và getContext của plugin đang
199
+ * sống để bắn progress và trả kết quả về đúng PluginCall.
200
+ */
201
+ private final class CompressionJob implements VideoCompressionWorker.Job {
202
+
203
+ private final String jobId;
204
+ private final Uri srcUri;
205
+ private final String fileName;
206
+ private final Configuration configuration;
207
+ private final AppSpecificStorageConfiguration storageConfig;
208
+ private final PluginCall call;
209
+ private final boolean holdForUpload;
210
+
211
+ /** Mở khi pha nén kết thúc, để worker biết lúc nào được trả về. */
212
+ private final CountDownLatch done = new CountDownLatch(1);
213
+
214
+ /** Mở khi caller gọi finishUpload - chỉ dùng khi holdForUpload. */
215
+ private final CountDownLatch released = new CountDownLatch(1);
216
+
217
+ /** Chốt một-lần cho mọi nhánh kết thúc; PluginCall chỉ được trả lời một lần. */
218
+ private final AtomicBoolean delivered = new AtomicBoolean(false);
219
+
220
+ /** True khi nén xong có kết quả dùng được, tức là có cái để upload. */
221
+ private volatile boolean compressed = false;
222
+
223
+ private boolean started = false;
224
+ private boolean stopped = false;
225
+
226
+ CompressionJob(
227
+ String jobId,
228
+ Uri srcUri,
229
+ String fileName,
230
+ Configuration configuration,
231
+ AppSpecificStorageConfiguration storageConfig,
232
+ PluginCall call,
233
+ boolean holdForUpload
234
+ ) {
235
+ this.jobId = jobId;
236
+ this.srcUri = srcUri;
237
+ this.fileName = fileName;
238
+ this.configuration = configuration;
239
+ this.storageConfig = storageConfig;
240
+ this.call = call;
241
+ this.holdForUpload = holdForUpload;
242
+ }
243
+
244
+ @Override
245
+ public void run() {
246
+ synchronized (this) {
247
+ // stop() đã chạy trước khi worker kịp khởi động: đừng bắt đầu
248
+ // một lần nén mà không còn ai chờ kết quả.
249
+ if (!stopped) {
250
+ VideoCompressor.start(
251
+ getContext(),
252
+ Collections.singletonList(srcUri), // Nhận vào một List chứa đường dẫn
253
+ false, // isStreamable
254
+ null, // sharedStorageConfiguration (Không dùng)
255
+ storageConfig, // Cấu hình lưu trữ
256
+ configuration, // Cấu hình chất lượng
257
+ listener()
258
+ );
259
+ started = true;
260
+ }
261
+ }
262
+
263
+ if (!started) {
264
+ deliverCancelled();
265
+ return;
266
+ }
267
+
268
+ try {
269
+ done.await();
270
+ } catch (InterruptedException e) {
271
+ Thread.currentThread().interrupt();
272
+ deliverCancelled();
273
+ return;
274
+ }
275
+
276
+ // Nén hỏng hoặc bị huỷ thì không có gì để upload - về ngay thay vì
277
+ // giữ process sống chờ một finishUpload không bao giờ tới.
278
+ if (!holdForUpload || !compressed) return;
279
+
280
+ try {
281
+ // Có hạn chứ không chờ vô tận: nếu caller chết giữa pha upload
282
+ // mà không gọi finishUpload, worker sẽ treo tới khi WorkManager
283
+ // cắt ở phút thứ 10. Về sớm hơn để trả quota job lại cho máy.
284
+ released.await(HOLD_FOR_UPLOAD_TIMEOUT_MINUTES, TimeUnit.MINUTES);
285
+ } catch (InterruptedException e) {
286
+ Thread.currentThread().interrupt();
287
+ }
288
+ }
289
+
290
+ @Override
291
+ public void release() {
292
+ released.countDown();
293
+ }
294
+
295
+ @Override
296
+ public void stop() {
297
+ boolean wasRunning;
298
+ synchronized (this) {
299
+ if (stopped) return;
300
+ stopped = true;
301
+ wasRunning = started;
302
+ }
303
+
304
+ // Trước mọi thứ khác: worker đang bị cắt, đừng để run() còn ngồi
305
+ // chờ finishUpload nữa.
306
+ released.countDown();
307
+
308
+ if (wasRunning) {
309
+ // onCancelled của LightCompressor sẽ mở chốt; không tự gọi
310
+ // deliverCancelled ở đây để nhánh dọn file tạm vẫn chạy.
311
+ try {
312
+ VideoCompressor.cancel();
313
+ } catch (Exception e) {
314
+ Log.w(TAG, "Could not cancel compression", e);
315
+ deliverCancelled();
316
+ }
192
317
  } else {
193
- getContext().startService(serviceIntent);
318
+ // Chưa bắt đầu thì không có callback nào bắn, phải tự kết thúc.
319
+ deliverCancelled();
194
320
  }
195
- } catch (Exception e) {
196
- synchronized (this) { currentJobId = null; }
197
- call.reject("Cannot start foreground video processing", e);
198
- return;
199
321
  }
200
322
 
201
- // 4. Bắt đầu nén (Đã cập nhật List Uri và int index)
202
- VideoCompressor.start(
203
- getContext(),
204
- Collections.singletonList(srcUri), // Nhận vào một List chứa đường dẫn
205
- false, // isStreamable
206
- null, // sharedStorageConfiguration (Không dùng)
207
- storageConfig, // Cấu hình lưu trữ
208
- configuration, // Cấu hình chất lượng
209
- new CompressionListener() {
323
+ private CompressionListener listener() {
324
+ return new CompressionListener() {
210
325
  @Override
211
326
  public void onProgress(int i, float v) {
212
- // Notification phải nhúc nhích theo đúng con số mà UI trong
213
- // app đang hiển thị: một notification đứng im là lý do Play
214
- // đánh trượt tiêu chí "user perceptible".
215
- VideoCompressionService.publishProgress(jobId, Math.round(v));
216
-
217
327
  // Gửi % tiến độ về Javascript để vẽ thanh Progress
218
328
  JSObject ret = new JSObject();
219
329
  ret.put("status", "progress");
@@ -223,13 +333,17 @@ public class NativeVideoCompressorPlugin extends Plugin {
223
333
 
224
334
  @Override
225
335
  public void onFailure(int i, @NonNull String failureMessage) {
226
- finishJob(jobId);
336
+ if (!claim()) return;
227
337
  call.reject("Nén thất bại: " + failureMessage);
228
338
  }
229
339
 
230
340
  @Override
231
341
  public void onSuccess(int i, long l, @Nullable String s) {
232
- finishJob(jobId);
342
+ // Phải đặt TRƯỚC claim(): claim() mở chốt `done`, và run()
343
+ // đọc `compressed` ngay khi chốt đó mở để quyết định có ở
344
+ // lại chờ upload hay không.
345
+ compressed = s != null;
346
+ if (!claim()) return;
233
347
  // Nén xong, trả kết quả đường dẫn file mới về cho JS
234
348
  JSObject ret = new JSObject();
235
349
  ret.put("success", true);
@@ -246,43 +360,43 @@ public class NativeVideoCompressorPlugin extends Plugin {
246
360
 
247
361
  @Override
248
362
  public void onCancelled(int i) {
249
- finishJob(jobId);
250
- // LightCompressor leaves whatever it had already written
251
- // behind; without this the cache keeps a partial mp4 per
252
- // cancel, and nothing ever collects them.
253
- deleteOutput(fileName);
254
- // Coded so the caller can tell "the user asked for this"
255
- // apart from a real failure — same code on every platform.
256
- call.reject("Đã hủy nén", CANCELLED_CODE);
363
+ deliverCancelled();
257
364
  }
365
+ };
366
+ }
367
+
368
+ private void deliverCancelled() {
369
+ if (!claim()) return;
370
+ // LightCompressor leaves whatever it had already written behind;
371
+ // without this the cache keeps a partial mp4 per cancel, and
372
+ // nothing ever collects them.
373
+ deleteOutput(fileName);
374
+ // Coded so the caller can tell "the user asked for this" apart
375
+ // from a real failure — same code on every platform.
376
+ call.reject("Đã hủy nén", CANCELLED_CODE);
377
+ }
378
+
379
+ /** True đúng một lần, cho nhánh kết thúc đầu tiên tới đích. */
380
+ private boolean claim() {
381
+ if (!delivered.compareAndSet(false, true)) return false;
382
+ synchronized (NativeVideoCompressorPlugin.this) {
383
+ if (jobId.equals(currentJobId)) currentJobId = null;
258
384
  }
259
- );
385
+ done.countDown();
386
+ return true;
387
+ }
260
388
  }
261
389
 
262
- /** Every terminal callback ends here, so neither is ever left running. */
263
- private void finishJob(String jobId) {
264
- sendToService(VideoCompressionService.ACTION_FINISH, jobId);
265
- synchronized (this) {
266
- currentJobId = null;
267
- }
390
+ private String workName(String jobId) {
391
+ return "video-compression:" + jobId;
268
392
  }
269
393
 
270
- /**
271
- * Lệnh gửi vào service, nuốt lỗi có chủ đích.
272
- *
273
- * startService() từ background bị chặn khi app không còn foreground service
274
- * nào chạy - đúng tình huống xảy ra khi service đã tự tắt bằng watchdog rồi
275
- * JS mới gọi finishUpload. Ném ra ở đây sẽ biến một lần dọn dẹp vô hại thành
276
- * một lần nén báo lỗi cho user.
277
- */
278
- private void sendToService(String action, String jobId) {
394
+ /** Best-effort: huỷ một work đã xong là no-op, không đáng để hỏng lời gọi cancel. */
395
+ private void cancelWork(String jobId) {
279
396
  try {
280
- Intent intent = new Intent(getContext(), VideoCompressionService.class);
281
- intent.setAction(action);
282
- intent.putExtra(VideoCompressionService.EXTRA_JOB_ID, jobId);
283
- getContext().startService(intent);
397
+ WorkManager.getInstance(getContext()).cancelUniqueWork(workName(jobId));
284
398
  } catch (Exception e) {
285
- Log.w("NativeVideoCompressor", "Could not send " + action + " to service", e);
399
+ Log.w(TAG, "Could not cancel work for " + jobId, e);
286
400
  }
287
401
  }
288
402
 
@@ -295,7 +409,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
295
409
  partial.delete();
296
410
  }
297
411
  } catch (Exception e) {
298
- Log.w("NativeVideoCompressor", "Could not delete cancelled output", e);
412
+ Log.w(TAG, "Could not delete cancelled output", e);
299
413
  }
300
414
  }
301
415
  }