capacitor-native-video-compressor 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,14 @@
1
1
  package com.atomic.videocompressor;
2
2
 
3
- import android.content.Intent;
3
+ import android.media.MediaMetadataRetriever;
4
4
  import android.net.Uri;
5
5
  import android.util.Log;
6
6
  import androidx.annotation.NonNull;
7
7
  import androidx.annotation.Nullable;
8
+ import androidx.work.Data;
9
+ import androidx.work.ExistingWorkPolicy;
10
+ import androidx.work.OneTimeWorkRequest;
11
+ import androidx.work.WorkManager;
8
12
  import com.abedelazizshe.lightcompressorlibrary.CompressionListener;
9
13
  import com.abedelazizshe.lightcompressorlibrary.VideoCompressor;
10
14
  import com.abedelazizshe.lightcompressorlibrary.VideoQuality;
@@ -16,13 +20,19 @@ import com.getcapacitor.PluginCall;
16
20
  import com.getcapacitor.PluginMethod;
17
21
  import com.getcapacitor.annotation.CapacitorPlugin;
18
22
  import java.io.File;
19
- import java.lang.ref.WeakReference;
20
23
  import java.util.Arrays;
21
24
  import java.util.Collections;
25
+ import java.util.HashSet;
26
+ import java.util.Set;
27
+ import java.util.concurrent.CountDownLatch;
28
+ import java.util.concurrent.TimeUnit;
29
+ import java.util.concurrent.atomic.AtomicBoolean;
22
30
 
23
31
  @CapacitorPlugin(name = "NativeVideoCompressor")
24
32
  public class NativeVideoCompressorPlugin extends Plugin {
25
33
 
34
+ private static final String TAG = "NativeVideoCompressor";
35
+
26
36
  /**
27
37
  * Matches the `CANCELLED` constant the JS side exports, so one
28
38
  * `error.code` check covers web, iOS and Android alike.
@@ -31,68 +41,133 @@ public class NativeVideoCompressorPlugin extends Plugin {
31
41
 
32
42
  private static final String STORAGE_SUBFOLDER = "compressed_videos";
33
43
 
44
+ /** Gắn lên mọi work của plugin để dọn được job mồ côi của phiên WebView trước. */
45
+ private static final String WORK_TAG = "native-video-compressor";
46
+
47
+ /** Thứ tự khớp NativeVideoCompressor.SOURCE_FACTORS. */
48
+ private static final VideoQuality[] QUALITY_BY_SOURCE_FACTOR = {
49
+ VideoQuality.VERY_LOW,
50
+ VideoQuality.LOW,
51
+ VideoQuality.MEDIUM,
52
+ VideoQuality.HIGH,
53
+ VideoQuality.VERY_HIGH
54
+ };
55
+
34
56
  /**
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.
57
+ * Trần cho pha giữ-chờ-upload. Ngắn hơn mốc 10 phút mà WorkManager tự cắt,
58
+ * để một caller chết giữa chừng không chiếm quota job tới tận phút cuối.
38
59
  */
39
- private static WeakReference<NativeVideoCompressorPlugin> active = new WeakReference<>(null);
60
+ private static final long HOLD_FOR_UPLOAD_TIMEOUT_MINUTES = 8;
61
+
62
+ /**
63
+ * Mốc process này nạp plugin - trước mọi lần nén của nó. Bản sao nguồn cũ
64
+ * hơn mốc này là của một process đã chết.
65
+ */
66
+ private static final long PROCESS_START_MS = System.currentTimeMillis();
40
67
 
41
68
  /** The job compressVideo is running right now, for cancel({ jobId }). */
42
69
  private String currentJobId = null;
43
70
 
71
+ /**
72
+ * Nothing to do natively; LightCompressor needs no warm-up. Answered
73
+ * rather than left unimplemented so a caller can await it unconditionally
74
+ * (the web build does need it, and shares the call site).
75
+ */
76
+ @PluginMethod
77
+ public void initialize(PluginCall call) {
78
+ JSObject ret = new JSObject();
79
+ ret.put("success", true);
80
+ ret.put("message", "No initialization needed on Android");
81
+ call.resolve(ret);
82
+ }
83
+
84
+ /** Bridge mới (Activity dựng lại): mọi job còn chạy thuộc phiên cũ. */
44
85
  @Override
45
86
  public void load() {
46
- super.load();
47
- active = new WeakReference<>(this);
87
+ cancelOrphans(PROCESS_START_MS);
48
88
  }
49
89
 
90
+ /**
91
+ * Bridge gọi hàm này ở onPageStarted (Bridge.reset), tức mỗi lần WebView
92
+ * tải lại trang - load() thì không chạy lại. JS cũ đã chết nên không còn
93
+ * ai nhận kết quả các job đang chạy.
94
+ *
95
+ * Mốc dọn là "bây giờ": JS mới chưa chạy nên mọi bản sao nguồn đều mồ
96
+ * côi, kể cả bản một job huỷ giữa pha chép vẫn đang ghi tiếp sau claim().
97
+ */
50
98
  @Override
51
- protected void handleOnDestroy() {
52
- if (active.get() == this) active = new WeakReference<>(null);
53
- super.handleOnDestroy();
99
+ public void removeAllListeners() {
100
+ super.removeAllListeners();
101
+ cancelOrphans(System.currentTimeMillis());
54
102
  }
55
103
 
56
104
  /**
57
- * User bấm "Huỷ" trên notification. Phải huỷ được cả hai pha:
105
+ * Dừng job trong process trước, đồng bộ, rồi mới huỷ work. Huỷ work là bất
106
+ * đồng bộ: onStopped tới muộn sẽ gọi VideoCompressor.cancel() - vốn global
107
+ * - và giết luôn job đầu tiên của phiên mới. Dừng trước thì onStopped gặp
108
+ * job đã stopped và không làm gì.
58
109
  *
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".
110
+ * ponytail: cờ isRunning của LightCompressor là global; nếu phiên mới gọi
111
+ * start trước khi vòng nén cũ kịp đọc cờ (mỗi ~100 µs) thì job cũ chạy
112
+ * tiếp. JS boot mất cả giây nên gần như không xảy ra; cần chặt hơn thì
113
+ * phải bỏ LightCompressor.
65
114
  */
66
- static void cancelAllFromNotification() {
115
+ private void cancelOrphans(long copiesOlderThan) {
116
+ VideoCompressionWorker.stopAll();
67
117
  try {
68
- VideoCompressor.cancel();
118
+ WorkManager.getInstance(getContext()).cancelAllWorkByTag(WORK_TAG);
69
119
  } catch (Exception e) {
70
- Log.w("NativeVideoCompressor", "Could not cancel compression", e);
120
+ Log.w(TAG, "Could not cancel orphaned work", e);
121
+ }
122
+ // Process bị kill giữa chừng thì không job nào kịp dọn bản sao của nó.
123
+ for (File copy : sourceCopies()) {
124
+ if (NativeVideoCompressor.isStaleSourceCopy(copy.getName(), copy.lastModified(), copiesOlderThan)) {
125
+ deleteQuietly(copy);
126
+ }
71
127
  }
72
- NativeVideoCompressorPlugin plugin = active.get();
73
- if (plugin == null) return;
74
- synchronized (plugin) {
75
- plugin.currentJobId = null;
128
+ }
129
+
130
+ /** File thường ở gốc dataDir mang tên đúng 13 chữ số - xem NativeVideoCompressor.isSourceCopyName. */
131
+ private File[] sourceCopies() {
132
+ try {
133
+ File[] files = new File(getContext().getApplicationInfo().dataDir).listFiles(
134
+ (File f) -> f.isFile() && NativeVideoCompressor.isSourceCopyName(f.getName())
135
+ );
136
+ if (files != null) return files;
137
+ } catch (Exception e) {
138
+ Log.w(TAG, "Could not list source copies", e);
76
139
  }
77
- plugin.notifyListeners("onCancelRequested", new JSObject());
140
+ return new File[0];
141
+ }
142
+
143
+ private Set<String> sourceCopyNames() {
144
+ Set<String> names = new HashSet<>();
145
+ for (File copy : sourceCopies()) names.add(copy.getName());
146
+ return names;
78
147
  }
79
148
 
80
149
  /**
81
- * Nothing to do natively; LightCompressor needs no warm-up. Answered
82
- * rather than left unimplemented so a caller can await it unconditionally
83
- * (the web build does need it, and shares the call site).
150
+ * Xoá bản sao xuất hiện sau `before`. Nếu job khác đang chạy song song mà
151
+ * bản sao của nó bị xoá theo cũng vô hại: LightCompressor 1.3.2 chỉ
152
+ * null-check đường dẫn đó, còn nén thì đọc thẳng từ Uri gốc.
84
153
  */
85
- @PluginMethod
86
- public void initialize(PluginCall call) {
87
- JSObject ret = new JSObject();
88
- ret.put("success", true);
89
- ret.put("message", "No initialization needed on Android");
90
- call.resolve(ret);
154
+ private void deleteSourceCopiesSince(Set<String> before) {
155
+ for (File copy : sourceCopies()) {
156
+ if (!before.contains(copy.getName())) deleteQuietly(copy);
157
+ }
158
+ }
159
+
160
+ private void deleteQuietly(File file) {
161
+ try {
162
+ if (!file.delete()) Log.w(TAG, "Could not delete " + file);
163
+ } catch (Exception e) {
164
+ Log.w(TAG, "Could not delete " + file, e);
165
+ }
91
166
  }
92
167
 
93
168
  /**
94
- * Stop the running compress. LightCompressor cancels its coroutine job and
95
- * fires onCancelled, which is already wired below.
169
+ * Stop the running compress. The job is stopped directly and answers
170
+ * CANCELLED itself; LightCompressor's own callbacks after that are no-ops.
96
171
  */
97
172
  @PluginMethod
98
173
  public void cancel(PluginCall call) {
@@ -104,12 +179,17 @@ public class NativeVideoCompressorPlugin extends Plugin {
104
179
  // running job could finish and a new one start in the gap — and
105
180
  // VideoCompressor.cancel() being global, it would kill the newcomer.
106
181
  // 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.
182
+ // Both calls are non-blocking (one cancels a coroutine job, the other
183
+ // returns an Operation), so holding the lock across them costs nothing.
109
184
  synchronized (this) {
110
185
  cancelled = currentJobId != null && (requested == null || requested.equals(currentJobId));
111
186
  if (cancelled) {
112
- VideoCompressor.cancel();
187
+ String jobId = currentJobId;
188
+ // Dừng job trực tiếp chứ không trông vào onStopped: work còn
189
+ // ENQUEUED thì huỷ xong WorkManager không hề dựng Worker, nên
190
+ // onStopped không bao giờ bắn và PluginCall sẽ treo.
191
+ if (!VideoCompressionWorker.stop(jobId)) VideoCompressor.cancel();
192
+ cancelWork(jobId);
113
193
  }
114
194
  }
115
195
 
@@ -117,6 +197,21 @@ public class NativeVideoCompressorPlugin extends Plugin {
117
197
  call.resolve(ret);
118
198
  }
119
199
 
200
+ /**
201
+ * Caller báo đã xong việc với kết quả nén - thường là upload xong - nên
202
+ * worker đang giữ process sống được phép kết thúc.
203
+ *
204
+ * 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
205
+ * 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ữ.
206
+ */
207
+ @PluginMethod
208
+ public void finishUpload(PluginCall call) {
209
+ String jobId = call.getString("jobId");
210
+ JSObject ret = new JSObject();
211
+ ret.put("released", jobId != null && VideoCompressionWorker.release(jobId));
212
+ call.resolve(ret);
213
+ }
214
+
120
215
  @PluginMethod
121
216
  public void compressVideo(PluginCall call) {
122
217
  String sourcePath = call.getString("sourcePath");
@@ -127,9 +222,6 @@ public class NativeVideoCompressorPlugin extends Plugin {
127
222
  }
128
223
 
129
224
  String jobId = call.getString("jobId", "default");
130
- synchronized (this) {
131
- currentJobId = jobId;
132
- }
133
225
 
134
226
  // 1. Chuẩn bị Uri và Tên file xuất ra
135
227
  Uri srcUri;
@@ -145,75 +237,232 @@ public class NativeVideoCompressorPlugin extends Plugin {
145
237
 
146
238
  // Đọc quality từ Javascript (Mặc định là MEDIUM)
147
239
  String qualityString = call.getString("quality", "MEDIUM");
240
+ NativeVideoCompressor.Target target = NativeVideoCompressor.target(qualityString);
148
241
 
149
- VideoQuality videoQuality = VideoQuality.MEDIUM;
150
- if (qualityString != null) {
151
- if (qualityString.equals("360P")) {
152
- videoQuality = VideoQuality.LOW; // Map 360p to LOW quality on Android
153
- } else if (qualityString.equals("LOW")) {
154
- videoQuality = VideoQuality.VERY_LOW; // Adjusting others dynamically if needed
155
- } else {
156
- try {
157
- videoQuality = VideoQuality.valueOf(qualityString);
158
- } catch (IllegalArgumentException e) {
159
- videoQuality = VideoQuality.MEDIUM;
160
- }
161
- }
242
+ // Kích thước + bitrate theo cùng bậc thang với iOS/web. Đọc metadata
243
+ // hỏng thì để null, LightCompressor tự chọn như trước.
244
+ int[] size = null;
245
+ NativeVideoCompressor.BitratePlan bitrate = null;
246
+ MediaMetadataRetriever retriever = new MediaMetadataRetriever();
247
+ try {
248
+ retriever.setDataSource(getContext(), srcUri);
249
+ int width = Integer.parseInt(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));
250
+ int height = Integer.parseInt(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT));
251
+ size = NativeVideoCompressor.outputSize(width, height, target.shortSide);
252
+ bitrate = NativeVideoCompressor.bitrate(
253
+ target.bitrate,
254
+ Long.parseLong(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE))
255
+ );
256
+ } catch (Exception e) {
257
+ // Containers without a bitrate tag (MKV, WebM, screen recordings) still keep the tier's size.
258
+ Log.w(TAG, "Could not read video metadata, using LightCompressor defaults for the rest", e);
259
+ } finally {
260
+ // release() khai báo IOException từ API 29; không dùng
261
+ // try-with-resources vì minSdk 24 chưa có AutoCloseable.
262
+ try {
263
+ retriever.release();
264
+ } catch (Exception ignored) {}
162
265
  }
163
266
 
267
+ // Với bitrate tính theo nguồn, quality chỉ còn chọn hệ số nhân của
268
+ // LightCompressor; với Mbps tường minh hoặc metadata hỏng thì giữ MEDIUM.
269
+ VideoQuality videoQuality =
270
+ bitrate != null && bitrate.mbps == null ? QUALITY_BY_SOURCE_FACTOR[bitrate.sourceFactorIndex] : VideoQuality.MEDIUM;
271
+
164
272
  Log.d(
165
- "NativeVideoCompressor",
166
- "====== VIDEO COMPRESSION QUALITY RECEIVED: " + qualityString + "+ video quality: " + videoQuality + " ======"
273
+ TAG,
274
+ "Compress " +
275
+ qualityString +
276
+ ": size=" +
277
+ (size == null ? "default" : size[0] + "x" + size[1]) +
278
+ ", mbps=" +
279
+ (bitrate == null ? "default" : bitrate.mbps) +
280
+ ", quality=" +
281
+ videoQuality
167
282
  );
168
283
 
169
284
  // 2. Cấu hình thông số nén (Dành riêng cho LightCompressor 1.3.2)
170
- // Lưu ý: Java phải truyền đủ 9 tham số do thư viện gốc viết bằng Kotlin
285
+ // Thứ tự tham số theo Configuration.kt: height ĐỨNG TRƯỚC width.
171
286
  Configuration configuration = new Configuration(
172
287
  videoQuality, // Chất lượng nén
173
288
  false, // tắt tính năng giới hạn Bitrate tối thiểu của file gốc
174
- null, // videoBitrateInMbps (Ép buộc giá trị Bitrate để đảm bảo file luôn nhẹ đi)
289
+ bitrate == null ? null : bitrate.mbps, // videoBitrateInMbps
175
290
  false, // disableAudio
176
291
  false, // keepOriginalResolution
177
- null, // videoWidth
178
- null, // videoHeight
292
+ size == null ? null : Double.valueOf(size[1]), // videoHeight
293
+ size == null ? null : Double.valueOf(size[0]), // videoWidth
179
294
  Arrays.asList(fileName) // videoNames
180
295
  );
181
296
 
182
297
  // 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
298
  AppSpecificStorageConfiguration storageConfig = new AppSpecificStorageConfiguration(STORAGE_SUBFOLDER);
184
299
 
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);
300
+ synchronized (this) {
301
+ currentJobId = jobId;
302
+ }
303
+
304
+ // 4. Giao pha nén cho WorkManager. Không nén thẳng ở đây: một thread
305
+ // thường bị đóng băng cùng process ngay khi user rời app, còn process
306
+ // đang chạy worker thì không. Đây là phương án Google Play chỉ đích
307
+ // danh khi từ chối foreground service ở version code 32.
308
+ boolean holdForUpload = Boolean.TRUE.equals(call.getBoolean("holdForUpload", false));
309
+ CompressionJob job = new CompressionJob(jobId, srcUri, fileName, configuration, storageConfig, call, holdForUpload);
310
+ VideoCompressionWorker.register(jobId, job);
311
+
312
+ OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(VideoCompressionWorker.class)
313
+ .setInputData(new Data.Builder().putString(VideoCompressionWorker.KEY_JOB_ID, jobId).build())
314
+ .addTag(WORK_TAG)
315
+ .build();
316
+
189
317
  try {
190
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
191
- getContext().startForegroundService(serviceIntent);
192
- } else {
193
- getContext().startService(serviceIntent);
194
- }
318
+ WorkManager.getInstance(getContext()).enqueueUniqueWork(workName(jobId), ExistingWorkPolicy.REPLACE, request);
195
319
  } catch (Exception e) {
196
- synchronized (this) { currentJobId = null; }
197
- call.reject("Cannot start foreground video processing", e);
198
- return;
320
+ VideoCompressionWorker.unregister(jobId);
321
+ synchronized (this) {
322
+ currentJobId = null;
323
+ }
324
+ call.reject("Cannot schedule video compression", e);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Một lần nén, chạy bên trong {@link VideoCompressionWorker}.
330
+ *
331
+ * Không phải static: nó cần notifyListeners và getContext của plugin đang
332
+ * sống để bắn progress và trả kết quả về đúng PluginCall.
333
+ */
334
+ private final class CompressionJob implements VideoCompressionWorker.Job {
335
+
336
+ private final String jobId;
337
+ private final Uri srcUri;
338
+ private final String fileName;
339
+ private final Configuration configuration;
340
+ private final AppSpecificStorageConfiguration storageConfig;
341
+ private final PluginCall call;
342
+ private final boolean holdForUpload;
343
+
344
+ /** Mở khi pha nén kết thúc, để worker biết lúc nào được trả về. */
345
+ private final CountDownLatch done = new CountDownLatch(1);
346
+
347
+ /** Mở khi caller gọi finishUpload - chỉ dùng khi holdForUpload. */
348
+ private final CountDownLatch released = new CountDownLatch(1);
349
+
350
+ /** 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. */
351
+ private final AtomicBoolean delivered = new AtomicBoolean(false);
352
+
353
+ /** True khi nén xong có kết quả dùng được, tức là có cái để upload. */
354
+ private volatile boolean compressed = false;
355
+
356
+ /** Bản sao nguồn có sẵn trước job này; claim() xoá những cái mới hơn. */
357
+ private volatile Set<String> copiesBefore = null;
358
+
359
+ private boolean started = false;
360
+ private boolean stopped = false;
361
+
362
+ CompressionJob(
363
+ String jobId,
364
+ Uri srcUri,
365
+ String fileName,
366
+ Configuration configuration,
367
+ AppSpecificStorageConfiguration storageConfig,
368
+ PluginCall call,
369
+ boolean holdForUpload
370
+ ) {
371
+ this.jobId = jobId;
372
+ this.srcUri = srcUri;
373
+ this.fileName = fileName;
374
+ this.configuration = configuration;
375
+ this.storageConfig = storageConfig;
376
+ this.call = call;
377
+ this.holdForUpload = holdForUpload;
199
378
  }
200
379
 
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() {
380
+ @Override
381
+ public void run() {
382
+ synchronized (this) {
383
+ // stop() đã chạy trước khi worker kịp khởi động: đừng bắt đầu
384
+ // một lần nén mà không còn ai chờ kết quả.
385
+ if (!stopped) {
386
+ copiesBefore = sourceCopyNames();
387
+ VideoCompressor.start(
388
+ getContext(),
389
+ Collections.singletonList(srcUri), // Nhận vào một List chứa đường dẫn
390
+ false, // isStreamable
391
+ null, // sharedStorageConfiguration (Không dùng)
392
+ storageConfig, // Cấu hình lưu trữ
393
+ configuration, // Cấu hình chất lượng
394
+ listener()
395
+ );
396
+ started = true;
397
+ }
398
+ }
399
+
400
+ if (!started) {
401
+ deliverCancelled();
402
+ return;
403
+ }
404
+
405
+ try {
406
+ done.await();
407
+ } catch (InterruptedException e) {
408
+ Thread.currentThread().interrupt();
409
+ deliverCancelled();
410
+ return;
411
+ }
412
+
413
+ // Nén hỏng hoặc bị huỷ thì không có gì để upload - về ngay thay vì
414
+ // giữ process sống chờ một finishUpload không bao giờ tới.
415
+ if (!holdForUpload || !compressed) return;
416
+
417
+ try {
418
+ // Có hạn chứ không chờ vô tận: nếu caller chết giữa pha upload
419
+ // mà không gọi finishUpload, worker sẽ treo tới khi WorkManager
420
+ // cắt ở phút thứ 10. Về sớm hơn để trả quota job lại cho máy.
421
+ released.await(HOLD_FOR_UPLOAD_TIMEOUT_MINUTES, TimeUnit.MINUTES);
422
+ } catch (InterruptedException e) {
423
+ Thread.currentThread().interrupt();
424
+ }
425
+ }
426
+
427
+ @Override
428
+ public void release() {
429
+ released.countDown();
430
+ }
431
+
432
+ @Override
433
+ public void stop() {
434
+ boolean wasRunning;
435
+ synchronized (this) {
436
+ if (stopped) return;
437
+ stopped = true;
438
+ wasRunning = started;
439
+ }
440
+
441
+ // Trước mọi thứ khác: worker đang bị cắt, đừng để run() còn ngồi
442
+ // chờ finishUpload nữa.
443
+ released.countDown();
444
+
445
+ // Đã trả kết quả (đang giữ cho upload) thì không còn gì của job này
446
+ // để huỷ - cancel() global lúc đó chỉ giết job khác đang nén.
447
+ if (wasRunning && !delivered.get()) {
448
+ try {
449
+ VideoCompressor.cancel();
450
+ } catch (Exception e) {
451
+ Log.w(TAG, "Could not cancel compression", e);
452
+ }
453
+ }
454
+ // Luôn tự kết thúc: coroutine của LightCompressor bị huỷ thường ném
455
+ // CancellationException mà không bắn callback nào (trước onStart,
456
+ // hay lúc processAudio/finishMovie không đọc isRunning). claim()
457
+ // biến onCancelled tới sau thành no-op, còn onSuccess lỡ tới thì
458
+ // tự xoá output.
459
+ deliverCancelled();
460
+ }
461
+
462
+ private CompressionListener listener() {
463
+ return new CompressionListener() {
210
464
  @Override
211
465
  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
466
  // Gửi % tiến độ về Javascript để vẽ thanh Progress
218
467
  JSObject ret = new JSObject();
219
468
  ret.put("status", "progress");
@@ -223,13 +472,23 @@ public class NativeVideoCompressorPlugin extends Plugin {
223
472
 
224
473
  @Override
225
474
  public void onFailure(int i, @NonNull String failureMessage) {
226
- finishJob(jobId);
475
+ if (!claim()) return;
476
+ deleteOutput(fileName);
227
477
  call.reject("Nén thất bại: " + failureMessage);
228
478
  }
229
479
 
230
480
  @Override
231
481
  public void onSuccess(int i, long l, @Nullable String s) {
232
- finishJob(jobId);
482
+ // Phải đặt TRƯỚC claim(): claim() mở chốt `done`, và run()
483
+ // đọc `compressed` ngay khi chốt đó mở để quyết định có ở
484
+ // lại chờ upload hay không.
485
+ compressed = s != null;
486
+ if (!claim()) {
487
+ // Đã trả CANCELLED trước đó (stop() tự kết thúc):
488
+ // không ai nhận file này.
489
+ deleteOutput(fileName);
490
+ return;
491
+ }
233
492
  // Nén xong, trả kết quả đường dẫn file mới về cho JS
234
493
  JSObject ret = new JSObject();
235
494
  ret.put("success", true);
@@ -246,56 +505,60 @@ public class NativeVideoCompressorPlugin extends Plugin {
246
505
 
247
506
  @Override
248
507
  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);
508
+ deliverCancelled();
257
509
  }
510
+ };
511
+ }
512
+
513
+ private void deliverCancelled() {
514
+ if (!claim()) return;
515
+ // LightCompressor leaves whatever it had already written behind;
516
+ // without this the cache keeps a partial mp4 per cancel, and
517
+ // nothing ever collects them.
518
+ deleteOutput(fileName);
519
+ // Coded so the caller can tell "the user asked for this" apart
520
+ // from a real failure — same code on every platform.
521
+ call.reject("Đã hủy nén", CANCELLED_CODE);
522
+ }
523
+
524
+ /** True đúng một lần, cho nhánh kết thúc đầu tiên tới đích. */
525
+ private boolean claim() {
526
+ if (!delivered.compareAndSet(false, true)) return false;
527
+ Set<String> before = copiesBefore;
528
+ if (before != null) deleteSourceCopiesSince(before);
529
+ synchronized (NativeVideoCompressorPlugin.this) {
530
+ if (jobId.equals(currentJobId)) currentJobId = null;
258
531
  }
259
- );
532
+ done.countDown();
533
+ return true;
534
+ }
260
535
  }
261
536
 
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
- }
537
+ private String workName(String jobId) {
538
+ return "video-compression:" + jobId;
268
539
  }
269
540
 
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) {
541
+ /** Best-effort: huỷ một work đã xong là no-op, không đáng để hỏng lời gọi cancel. */
542
+ private void cancelWork(String jobId) {
279
543
  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);
544
+ WorkManager.getInstance(getContext()).cancelUniqueWork(workName(jobId));
284
545
  } catch (Exception e) {
285
- Log.w("NativeVideoCompressor", "Could not send " + action + " to service", e);
546
+ Log.w(TAG, "Could not cancel work for " + jobId, e);
286
547
  }
287
548
  }
288
549
 
289
- /** Best-effort: a partial file left in the cache is not worth failing over. */
550
+ /** Best-effort: a partial file left behind is not worth failing over. */
290
551
  private void deleteOutput(String fileName) {
291
552
  try {
292
- File dir = new File(getContext().getExternalFilesDir(null), STORAGE_SUBFOLDER);
553
+ // Nơi LightCompressor 1.3.2 ghi với AppSpecificStorageConfiguration:
554
+ // File(context.filesDir, "<subFolder>/<name>").
555
+ File dir = new File(getContext().getFilesDir(), STORAGE_SUBFOLDER);
293
556
  File partial = new File(dir, fileName);
294
557
  if (partial.exists()) {
295
558
  partial.delete();
296
559
  }
297
560
  } catch (Exception e) {
298
- Log.w("NativeVideoCompressor", "Could not delete cancelled output", e);
561
+ Log.w(TAG, "Could not delete cancelled output", e);
299
562
  }
300
563
  }
301
564
  }