capacitor-native-video-compressor 1.1.3 → 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.
@@ -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,13 +91,19 @@ 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
+ */
120
101
  @PluginMethod
121
102
  public void finishUpload(PluginCall call) {
122
103
  String jobId = call.getString("jobId");
123
- if (jobId != null) {
124
- sendToService(VideoCompressionService.ACTION_FINISH, jobId);
125
- }
126
- call.resolve();
104
+ JSObject ret = new JSObject();
105
+ ret.put("released", jobId != null && VideoCompressionWorker.release(jobId));
106
+ call.resolve(ret);
127
107
  }
128
108
 
129
109
  @PluginMethod
@@ -136,10 +116,6 @@ public class NativeVideoCompressorPlugin extends Plugin {
136
116
  }
137
117
 
138
118
  String jobId = call.getString("jobId", "default");
139
- boolean holdForUpload = Boolean.TRUE.equals(call.getBoolean("holdForUpload", false));
140
- synchronized (this) {
141
- currentJobId = jobId;
142
- }
143
119
 
144
120
  // 1. Chuẩn bị Uri và Tên file xuất ra
145
121
  Uri srcUri;
@@ -171,10 +147,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
171
147
  }
172
148
  }
173
149
 
174
- Log.d(
175
- "NativeVideoCompressor",
176
- "====== VIDEO COMPRESSION QUALITY RECEIVED: " + qualityString + "+ video quality: " + videoQuality + " ======"
177
- );
150
+ Log.d(TAG, "====== VIDEO COMPRESSION QUALITY RECEIVED: " + qualityString + "+ video quality: " + videoQuality + " ======");
178
151
 
179
152
  // 2. Cấu hình thông số nén (Dành riêng cho LightCompressor 1.3.2)
180
153
  // Lưu ý: Java phải truyền đủ 9 tham số do thư viện gốc viết bằng Kotlin
@@ -192,38 +165,165 @@ public class NativeVideoCompressorPlugin extends Plugin {
192
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)
193
166
  AppSpecificStorageConfiguration storageConfig = new AppSpecificStorageConfiguration(STORAGE_SUBFOLDER);
194
167
 
195
- // Khởi động Foreground Service để giữ app luôn sống khi chạy ngầm
196
- Intent serviceIntent = new Intent(getContext(), VideoCompressionService.class);
197
- serviceIntent.setAction(VideoCompressionService.ACTION_START);
198
- 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
+
199
184
  try {
200
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
201
- 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
+ }
202
317
  } else {
203
- 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();
204
320
  }
205
- } catch (Exception e) {
206
- synchronized (this) { currentJobId = null; }
207
- call.reject("Cannot start foreground video processing", e);
208
- return;
209
321
  }
210
322
 
211
- // 4. Bắt đầu nén (Đã cập nhật List Uri và int index)
212
- VideoCompressor.start(
213
- getContext(),
214
- Collections.singletonList(srcUri), // Nhận vào một List chứa đường dẫn
215
- false, // isStreamable
216
- null, // sharedStorageConfiguration (Không dùng)
217
- storageConfig, // Cấu hình lưu trữ
218
- configuration, // Cấu hình chất lượng
219
- new CompressionListener() {
323
+ private CompressionListener listener() {
324
+ return new CompressionListener() {
220
325
  @Override
221
326
  public void onProgress(int i, float v) {
222
- // Notification phải nhúc nhích theo đúng con số mà UI trong
223
- // app đang hiển thị: một notification đứng im là lý do Play
224
- // đánh trượt tiêu chí "user perceptible".
225
- VideoCompressionService.publishProgress(jobId, Math.round(v));
226
-
227
327
  // Gửi % tiến độ về Javascript để vẽ thanh Progress
228
328
  JSObject ret = new JSObject();
229
329
  ret.put("status", "progress");
@@ -233,13 +333,17 @@ public class NativeVideoCompressorPlugin extends Plugin {
233
333
 
234
334
  @Override
235
335
  public void onFailure(int i, @NonNull String failureMessage) {
236
- finishJob(jobId, false);
336
+ if (!claim()) return;
237
337
  call.reject("Nén thất bại: " + failureMessage);
238
338
  }
239
339
 
240
340
  @Override
241
341
  public void onSuccess(int i, long l, @Nullable String s) {
242
- finishJob(jobId, holdForUpload);
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;
243
347
  // Nén xong, trả kết quả đường dẫn file mới về cho JS
244
348
  JSObject ret = new JSObject();
245
349
  ret.put("success", true);
@@ -256,46 +360,43 @@ public class NativeVideoCompressorPlugin extends Plugin {
256
360
 
257
361
  @Override
258
362
  public void onCancelled(int i) {
259
- finishJob(jobId, false);
260
- // LightCompressor leaves whatever it had already written
261
- // behind; without this the cache keeps a partial mp4 per
262
- // cancel, and nothing ever collects them.
263
- deleteOutput(fileName);
264
- // Coded so the caller can tell "the user asked for this"
265
- // apart from a real failure — same code on every platform.
266
- call.reject("Đã hủy nén", CANCELLED_CODE);
363
+ deliverCancelled();
267
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;
268
384
  }
269
- );
385
+ done.countDown();
386
+ return true;
387
+ }
270
388
  }
271
389
 
272
- /** Every terminal callback ends here, so neither is ever left running. */
273
- private void finishJob(String jobId, boolean holdForUpload) {
274
- sendToService(
275
- holdForUpload ? VideoCompressionService.ACTION_UPLOAD : VideoCompressionService.ACTION_FINISH,
276
- jobId
277
- );
278
- synchronized (this) {
279
- currentJobId = null;
280
- }
390
+ private String workName(String jobId) {
391
+ return "video-compression:" + jobId;
281
392
  }
282
393
 
283
- /**
284
- * Lệnh gửi vào service, nuốt lỗi có chủ đích.
285
- *
286
- * startService() từ background bị chặn khi app không còn foreground service
287
- * nào chạy - đúng tình huống xảy ra khi service đã tự tắt bằng watchdog rồi
288
- * 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
289
- * một lần nén báo lỗi cho user.
290
- */
291
- 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) {
292
396
  try {
293
- Intent intent = new Intent(getContext(), VideoCompressionService.class);
294
- intent.setAction(action);
295
- intent.putExtra(VideoCompressionService.EXTRA_JOB_ID, jobId);
296
- getContext().startService(intent);
397
+ WorkManager.getInstance(getContext()).cancelUniqueWork(workName(jobId));
297
398
  } catch (Exception e) {
298
- Log.w("NativeVideoCompressor", "Could not send " + action + " to service", e);
399
+ Log.w(TAG, "Could not cancel work for " + jobId, e);
299
400
  }
300
401
  }
301
402
 
@@ -308,7 +409,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
308
409
  partial.delete();
309
410
  }
310
411
  } catch (Exception e) {
311
- Log.w("NativeVideoCompressor", "Could not delete cancelled output", e);
412
+ Log.w(TAG, "Could not delete cancelled output", e);
312
413
  }
313
414
  }
314
415
  }
@@ -0,0 +1,120 @@
1
+ package com.atomic.videocompressor;
2
+
3
+ import android.content.Context;
4
+ import androidx.annotation.NonNull;
5
+ import androidx.annotation.Nullable;
6
+ import androidx.work.Worker;
7
+ import androidx.work.WorkerParameters;
8
+ import java.util.Map;
9
+ import java.util.concurrent.ConcurrentHashMap;
10
+
11
+ /**
12
+ * Giữ pha nén chạy được khi user rời app, không cần foreground service.
13
+ *
14
+ * Android 14+ đóng băng process ở trạng thái cached, nên nén bằng một thread
15
+ * thường sẽ đứng lại ngay khi user chuyển app. Foreground service từng là cách
16
+ * giữ nó sống, nhưng Google Play từ chối version code 32: use case này được xếp
17
+ * vào data sync + local processing, không đủ điều kiện cho
18
+ * FOREGROUND_SERVICE_MEDIA_PROCESSING, và họ chỉ đích danh WorkManager làm
19
+ * phương án thay thế. Process đang chạy một worker thì không bị đóng băng.
20
+ *
21
+ * Worker chỉ là vỏ bọc: việc nén thật do plugin đăng ký qua {@link #register}
22
+ * ngay trước khi enqueue, vì chỉ plugin mới giữ được PluginCall để trả kết quả.
23
+ *
24
+ * Với `holdForUpload`, worker ở lại sau khi nén xong cho tới khi caller gọi
25
+ * `finishUpload`. Pha upload chạy bằng JS trong webview, và khoảnh khắc worker
26
+ * kết thúc là khoảnh khắc process thành cached rồi bị đóng băng - đo trên
27
+ * Android 16: upload chết đúng ~5 giây sau khi worker trả về. Giữ worker sống
28
+ * xuyên pha upload là cách duy nhất còn lại để nó sống sót, vì foreground
29
+ * service đã bị Play cấm cho use case này.
30
+ *
31
+ * Giới hạn phải biết: WorkManager dừng worker sau 10 phút, tính chung cả nén
32
+ * lẫn upload. Quá mức đó {@link #onStopped()} huỷ việc đang chạy và caller
33
+ * nhận lỗi CANCELLED - đổi lại lấy được nhiều thời gian hơn thì phải có
34
+ * foreground service, thứ không còn được phép ở đây.
35
+ */
36
+ public class VideoCompressionWorker extends Worker {
37
+
38
+ static final String KEY_JOB_ID = "jobId";
39
+
40
+ /**
41
+ * Việc nén do plugin đăng ký, tra theo jobId.
42
+ *
43
+ * Static vì worker được WorkManager dựng mới, không nhận được tham chiếu
44
+ * nào từ phía plugin. Map chỉ có nghĩa trong phạm vi một process: process
45
+ * chết là WebView chết, không còn ai nhận kết quả nén nữa.
46
+ */
47
+ private static final Map<String, Job> pending = new ConcurrentHashMap<>();
48
+
49
+ interface Job {
50
+ /**
51
+ * Chạy pha nén, và với holdForUpload thì ở lại tới khi caller gọi
52
+ * finishUpload. Chỉ trả về khi không còn gì phải giữ process sống.
53
+ */
54
+ void run();
55
+
56
+ /** WorkManager đòi dừng - hết 10 phút, hoặc work bị cancel. */
57
+ void stop();
58
+
59
+ /** Caller báo đã upload xong; worker được phép kết thúc. */
60
+ void release();
61
+ }
62
+
63
+ static void register(String jobId, Job job) {
64
+ pending.put(jobId, job);
65
+ }
66
+
67
+ static void unregister(String jobId) {
68
+ pending.remove(jobId);
69
+ }
70
+
71
+ /**
72
+ * Thả worker đang giữ process sống cho pha upload của jobId.
73
+ *
74
+ * False khi không còn job nào mang id đó - worker đã kết thúc từ trước,
75
+ * không có gì để thả. Đó là trạng thái bình thường, không phải lỗi.
76
+ */
77
+ static boolean release(String jobId) {
78
+ Job job = pending.get(jobId);
79
+ if (job == null) return false;
80
+ job.release();
81
+ return true;
82
+ }
83
+
84
+ @Nullable
85
+ private volatile Job running = null;
86
+
87
+ public VideoCompressionWorker(@NonNull Context context, @NonNull WorkerParameters params) {
88
+ super(context, params);
89
+ }
90
+
91
+ @NonNull
92
+ @Override
93
+ public Result doWork() {
94
+ String jobId = getInputData().getString(KEY_JOB_ID);
95
+ if (jobId == null) return Result.success();
96
+
97
+ Job job = pending.get(jobId);
98
+ // Không còn job: process đã bị kill và WorkManager dựng lại worker ở
99
+ // process mới. WebView đã chết cùng nó nên không còn ai nhận kết quả -
100
+ // chạy lại pha nén chỉ tốn pin. Success chứ không retry, để WorkManager
101
+ // bỏ hẳn work này đi.
102
+ if (job == null) return Result.success();
103
+
104
+ running = job;
105
+ try {
106
+ job.run();
107
+ } finally {
108
+ running = null;
109
+ pending.remove(jobId);
110
+ }
111
+ return Result.success();
112
+ }
113
+
114
+ @Override
115
+ public void onStopped() {
116
+ Job job = running;
117
+ if (job != null) job.stop();
118
+ super.onStopped();
119
+ }
120
+ }
package/dist/docs.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "returns": "Promise<CompressResult>",
19
19
  "tags": [],
20
- "docs": "",
20
+ "docs": "Compress one video and resolve with the path of the result.\n\nAndroid: the work runs inside a WorkManager worker, which keeps the\nprocess out of the frozen cached state while the user is away — there is\nno foreground service and no notification, because Google Play classes\nthis use case as data sync plus local processing and rejects\n`FOREGROUND_SERVICE_MEDIA_PROCESSING` for it. WorkManager stops a worker\nafter 10 minutes, so a compress that runs longer rejects with\n{@link CANCELLED}.",
21
21
  "complexTypes": [
22
22
  "CompressResult",
23
23
  "CompressOptions"
@@ -43,19 +43,9 @@
43
43
  ],
44
44
  "slug": "cancel"
45
45
  },
46
- {
47
- "name": "initialize",
48
- "signature": "() => Promise<{ success: boolean; message?: string; }>",
49
- "parameters": [],
50
- "returns": "Promise<{ success: boolean; message?: string | undefined; }>",
51
- "tags": [],
52
- "docs": "",
53
- "complexTypes": [],
54
- "slug": "initialize"
55
- },
56
46
  {
57
47
  "name": "finishUpload",
58
- "signature": "(options: { jobId: string; }) => Promise<void>",
48
+ "signature": "(options: { jobId: string; }) => Promise<{ released: boolean; }>",
59
49
  "parameters": [
60
50
  {
61
51
  "name": "options",
@@ -63,12 +53,22 @@
63
53
  "type": "{ jobId: string; }"
64
54
  }
65
55
  ],
66
- "returns": "Promise<void>",
56
+ "returns": "Promise<{ released: boolean; }>",
67
57
  "tags": [],
68
- "docs": "Báo rằng phần upload đi kèm `holdForUpload` đã xong (thành công hay thất\nbại đều phải gọi) để Android hạ foreground service xuống.\n\nNo-op trên web và iOS.",
58
+ "docs": "Release the worker that `holdForUpload` kept alive for this job.\n\nCall it on every terminal branch of the upload, success or failure. A\nmissed call leaves the device feeding an idle job until the hold expires.\n`released` is false when no job by that id was still being held, which is\nthe normal outcome of a late or duplicate call, not an error.",
69
59
  "complexTypes": [],
70
60
  "slug": "finishupload"
71
61
  },
62
+ {
63
+ "name": "initialize",
64
+ "signature": "() => Promise<{ success: boolean; message?: string; }>",
65
+ "parameters": [],
66
+ "returns": "Promise<{ success: boolean; message?: string | undefined; }>",
67
+ "tags": [],
68
+ "docs": "",
69
+ "complexTypes": [],
70
+ "slug": "initialize"
71
+ },
72
72
  {
73
73
  "name": "addListener",
74
74
  "signature": "(eventName: 'onProgress', listenerFunc: (info: { status: string; percent?: number; }) => void) => Promise<PluginListenerHandle>",
@@ -91,29 +91,6 @@
91
91
  "PluginListenerHandle"
92
92
  ],
93
93
  "slug": "addlisteneronprogress-"
94
- },
95
- {
96
- "name": "addListener",
97
- "signature": "(eventName: 'onCancelRequested', listenerFunc: () => void) => Promise<PluginListenerHandle>",
98
- "parameters": [
99
- {
100
- "name": "eventName",
101
- "docs": "",
102
- "type": "'onCancelRequested'"
103
- },
104
- {
105
- "name": "listenerFunc",
106
- "docs": "",
107
- "type": "() => void"
108
- }
109
- ],
110
- "returns": "Promise<PluginListenerHandle>",
111
- "tags": [],
112
- "docs": "Android: user đã bấm \"Huỷ\" trên notification của foreground service.\n\nPha nén được plugin tự huỷ trước khi event này bắn, nhưng pha upload chạy\ntrong JS nên không ai dừng hộ được - caller phải tự abort request đang\nchạy. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ, và Play đánh\ntrượt tiêu chí \"can be terminated or stopped by the user\".",
113
- "complexTypes": [
114
- "PluginListenerHandle"
115
- ],
116
- "slug": "addlisteneroncancelrequested-"
117
94
  }
118
95
  ],
119
96
  "properties": []
@@ -180,7 +157,7 @@
180
157
  {
181
158
  "name": "holdForUpload",
182
159
  "tags": [],
183
- "docs": "Android: giữ foreground service sống sau khi nén xong, để phần upload do\nJS chạy tiếp theo không bị hệ thống giết khi user rời app.\n\nBật cờ này là một lời hứa: caller PHẢI gọi {@link NativeVideoCompressorPlugin.finishUpload}\nkhi upload kết thúc, kể cả ở nhánh lỗi. Không gọi thì service chỉ còn\nwatchdog dọn hộ sau 15 phút, và trong 15 phút đó user nhìn thấy một\nnotification nói rằng app đang làm việc trong khi nó không làm gì cả.\n\nKhông có tác dụng trên web và iOS.",
160
+ "docs": "Android only: keep the background worker alive after the compress ends,\nuntil `finishUpload({ jobId })` is called.\n\nThe upload runs as JavaScript in the webview, and the moment the worker\nreturns the process becomes cached and is frozen — measured on Android 16,\nan in-flight upload dies about five seconds later. Set this whenever the\ncompressed file is about to be uploaded, and always pair it with a\n`finishUpload` in a `finally`.",
184
161
  "complexTypes": [],
185
162
  "type": "boolean | undefined"
186
163
  }