capacitor-native-video-compressor 1.1.1 → 1.1.3

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.
@@ -3,109 +3,326 @@ package com.atomic.videocompressor;
3
3
  import android.app.Notification;
4
4
  import android.app.NotificationChannel;
5
5
  import android.app.NotificationManager;
6
+ import android.app.PendingIntent;
6
7
  import android.app.Service;
7
8
  import android.content.Intent;
8
9
  import android.content.pm.ServiceInfo;
9
10
  import android.os.Build;
11
+ import android.os.Handler;
10
12
  import android.os.IBinder;
13
+ import android.os.Looper;
14
+ import android.os.SystemClock;
11
15
  import android.util.Log;
12
16
  import androidx.annotation.Nullable;
13
17
  import androidx.core.app.NotificationCompat;
18
+ import androidx.core.app.ServiceCompat;
19
+ import java.util.LinkedHashMap;
20
+ import java.util.Map;
14
21
 
22
+ /**
23
+ * Giữ tiến trình nén + upload sống khi user rời app.
24
+ *
25
+ * Mọi thứ trong đây phải đáp ứng được 5 tiêu chí foreground service của Google
26
+ * Play, vì bản thiếu chúng (1.1.0) đã bị từ chối ở version code 29:
27
+ *
28
+ * - user perceptible -> notification có progress thật, cập nhật theo % nén
29
+ * và theo pha (nén / tải lên), chứ không phải một dòng chữ tĩnh
30
+ * - stoppable by user -> action "Huỷ" ngay trên notification, vì khi user đã
31
+ * rời app thì UI trong app không còn với tới được
32
+ * - runs only as long as necessary -> watchdog tự tắt khi không còn ai báo
33
+ * tiến trình, để một lần JS chết giữa đường không để lại FGS chạy vô hạn
34
+ */
15
35
  public class VideoCompressionService extends Service {
16
36
 
17
37
  private static final String TAG = "VideoCompressionService";
18
38
  private static final String CHANNEL_ID = "VideoCompressionChannel";
19
39
  private static final int NOTIFICATION_ID = 1001;
20
40
 
41
+ static final String ACTION_START = "com.atomic.videocompressor.START";
42
+ static final String ACTION_UPLOAD = "com.atomic.videocompressor.UPLOAD";
43
+ static final String ACTION_FINISH = "com.atomic.videocompressor.FINISH";
44
+ /** Do chính user bấm từ notification. */
45
+ static final String ACTION_CANCEL = "com.atomic.videocompressor.CANCEL";
46
+
47
+ static final String EXTRA_JOB_ID = "jobId";
48
+ static final String EXTRA_PERCENT = "percent";
49
+
21
50
  /**
22
- * Lệnh kết thúc, do plugin gửi qua chính hàng đợi command của service thay vì
23
- * gọi stopService() từ ngoài.
51
+ * Không nhận được update nào trong khoảng này thì coi như phía gọi đã chết
52
+ * (WebView bị kill, hoặc một exception thoát ra ngoài nhánh finishUpload) và
53
+ * tự dừng. Không có nó, `jobs` không bao giờ rỗng và service chạy mãi với
54
+ * một notification treo - đúng thứ khiến Play đánh trượt tiêu chí "runs only
55
+ * for as long as necessary".
24
56
  *
25
- * stopService() và startForegroundService() là hai lệnh độc lập đi qua
26
- * ActivityManager, nên không có gì bảo đảm thứ tự: nén lỗi trong vòng ~90ms
27
- * (video hỏng) thì lệnh stop tới trước khi onStartCommand kịp chạy, AMS thấy
28
- * "Bringing down service while still waiting for start foreground" và ném
29
- * ForegroundServiceDidNotStartInTimeException - chết cả app.
57
+ * Chọn 15 phút: dài hơn mọi khoảng lặng hợp lệ (upload một video đã nén trên
58
+ * mạng chậm, hoặc pha nén chưa kịp bắn progress đầu tiên), ngắn hơn nhiều so
59
+ * với mức mà user kịp nhận ra có gì đó treo.
60
+ */
61
+ private static final long WATCHDOG_MS = 15 * 60 * 1000L;
62
+
63
+ /** Đừng vẽ lại notification mỗi tick progress của LightCompressor. */
64
+ private static final long MIN_REDRAW_INTERVAL_MS = 500L;
65
+
66
+ private enum Phase { COMPRESSING, UPLOADING }
67
+
68
+ private static final class Job {
69
+ Phase phase = Phase.COMPRESSING;
70
+ int percent = 0;
71
+ }
72
+
73
+ /** LinkedHashMap: notification một job hiển thị đúng job đã bắt đầu trước. */
74
+ private final Map<String, Job> jobs = new LinkedHashMap<>();
75
+
76
+ private final Handler handler = new Handler(Looper.getMainLooper());
77
+ private final Runnable watchdog = this::onWatchdogFired;
78
+
79
+ private long lastRedrawUptime = 0;
80
+ private boolean inForeground = false;
81
+
82
+ /**
83
+ * Instance đang sống, để plugin đẩy % vào mà không phải gửi một Intent cho
84
+ * mỗi tick.
30
85
  *
31
- * Command của service thì được dispatch tuần tự trên main thread, nên STOP
32
- * luôn chạy sau START. Mỗi onStartCommand đều gọi startForeground() trước,
33
- * kể cả nhánh STOP, để mọi startForegroundService() đang treo đều được đáp ứng.
86
+ * volatile vì được đọc từ thread nền: LightCompressor gọi onProgress thẳng
87
+ * từ coroutine encoder (Dispatchers.Default), không hop về main - đã kiểm
88
+ * bằng bytecode, onProgressChanged gọi listener trực tiếp không qua
89
+ * withContext. Thiếu volatile thì thread đó có thể đọc phải một tham chiếu
90
+ * cũ của service đã destroy.
34
91
  */
35
- static final String ACTION_STOP = "com.atomic.videocompressor.action.STOP";
92
+ @Nullable
93
+ private static volatile VideoCompressionService instance = null;
36
94
 
37
95
  @Override
38
96
  public void onCreate() {
39
97
  super.onCreate();
40
- createNotificationChannel();
98
+ instance = this;
99
+ NotificationChannel channel = new NotificationChannel(
100
+ CHANNEL_ID, "Xử lý và tải video lên", NotificationManager.IMPORTANCE_LOW
101
+ );
102
+ channel.setDescription("Tiến trình nén và tải video lên");
103
+ NotificationManager manager = getSystemService(NotificationManager.class);
104
+ if (manager != null) manager.createNotificationChannel(channel);
105
+ }
106
+
107
+ @Override
108
+ public void onDestroy() {
109
+ handler.removeCallbacks(watchdog);
110
+ if (instance == this) instance = null;
111
+ super.onDestroy();
112
+ }
113
+
114
+ /**
115
+ * Cập nhật tiến độ nén. Gọi được từ thread bất kỳ.
116
+ *
117
+ * No-op khi service chưa/không còn sống: progress là thông tin trang trí,
118
+ * không đáng để dựng lại một foreground service đã tắt.
119
+ */
120
+ static void publishProgress(String jobId, int percent) {
121
+ VideoCompressionService service = instance;
122
+ if (service == null) return;
123
+ // Chuyển về main thread: lời gọi tới đây nằm trên thread encoder của
124
+ // LightCompressor, còn jobs/handler/notification đều chỉ được chạm từ
125
+ // main. Không post thì LinkedHashMap bị sửa đồng thời với onStartCommand.
126
+ service.handler.post(() -> service.applyProgress(jobId, percent));
127
+ }
128
+
129
+ private void applyProgress(String jobId, int percent) {
130
+ Job job = jobs.get(jobId);
131
+ if (job == null) return;
132
+
133
+ int clamped = Math.max(0, Math.min(100, percent));
134
+ // Mỗi update đều gia hạn watchdog, kể cả khi bỏ qua việc vẽ lại:
135
+ // throttle chỉ để tiết kiệm vẽ, không phải dấu hiệu job đã chết.
136
+ armWatchdog();
137
+ if (job.percent == clamped) return;
138
+ job.percent = clamped;
139
+
140
+ long now = SystemClock.uptimeMillis();
141
+ if (now - lastRedrawUptime < MIN_REDRAW_INTERVAL_MS && clamped < 100) return;
142
+ lastRedrawUptime = now;
143
+ render();
41
144
  }
42
145
 
43
146
  @Override
44
147
  public int onStartCommand(Intent intent, int flags, int startId) {
45
- enterForeground();
148
+ if (intent == null) {
149
+ // START_NOT_STICKY nên chỉ xảy ra khi hệ thống restart service;
150
+ // không có job nào để phục hồi, dừng cho sạch.
151
+ stopEverything();
152
+ return START_NOT_STICKY;
153
+ }
154
+
155
+ String action = intent.getAction();
46
156
 
47
- if (intent != null && ACTION_STOP.equals(intent.getAction())) {
48
- stopForegroundCompat();
49
- stopSelf();
157
+ if (ACTION_CANCEL.equals(action)) {
158
+ // Notification là thứ duy nhất user còn thấy khi đã rời app, nên
159
+ // nút này huỷ tất cả: không có UI nào ở đây để chọn từng job.
160
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
161
+ stopEverything();
162
+ return START_NOT_STICKY;
50
163
  }
51
164
 
165
+ String jobId = intent.getStringExtra(EXTRA_JOB_ID);
166
+ if (jobId == null) return START_NOT_STICKY;
167
+
168
+ if (ACTION_START.equals(action)) {
169
+ jobs.put(jobId, new Job());
170
+ } else if (ACTION_UPLOAD.equals(action)) {
171
+ Job job = jobs.get(jobId);
172
+ if (job != null) {
173
+ job.phase = Phase.UPLOADING;
174
+ // Pha upload không có % nào bắn về, nên thanh progress chuyển
175
+ // sang indeterminate thay vì đứng im ở con số của pha nén.
176
+ job.percent = -1;
177
+ }
178
+ } else if (ACTION_FINISH.equals(action)) {
179
+ jobs.remove(jobId);
180
+ }
181
+
182
+ if (jobs.isEmpty()) {
183
+ stopEverything();
184
+ return START_NOT_STICKY;
185
+ }
186
+
187
+ armWatchdog();
188
+ lastRedrawUptime = SystemClock.uptimeMillis();
189
+ render();
52
190
  return START_NOT_STICKY;
53
191
  }
54
192
 
55
- private void enterForeground() {
193
+ /**
194
+ * startForeground() phải được gọi trong mọi nhánh của onStartCommand đến
195
+ * được từ startForegroundService(), kể cả nhánh dừng ngay: nếu không,
196
+ * Android giết cả app bằng ForegroundServiceDidNotStartInTimeException.
197
+ */
198
+ private void render() {
199
+ Job shown = jobs.values().iterator().next();
200
+ int running = jobs.size();
201
+ boolean indeterminate = shown.percent < 0;
202
+
203
+ String title;
204
+ if (running > 1) {
205
+ title = "Đang xử lý " + running + " video";
206
+ } else {
207
+ title = shown.phase == Phase.UPLOADING ? "Đang tải video lên" : "Đang nén video";
208
+ }
209
+
210
+ // Hai pha nói hai điều khác nhau, vì chúng chạy ở hai nơi khác nhau.
211
+ //
212
+ // Nén chạy ở native trong chính service này nên vẫn chạy khi app
213
+ // xuống nền. Upload thì chạy bằng JS trong webview, nên nó đứng lại
214
+ // và chỉ tiếp tục khi app trở lại foreground - đo được: nén xong là
215
+ // mọi hoạt động mạng dừng hẳn cho tới khi watchdog tự dừng service
216
+ // sau 15 phút. Vì vậy pha upload phải nói rõ việc người dùng cần làm.
217
+ String text;
218
+ if (shown.phase == Phase.UPLOADING) {
219
+ text = indeterminate
220
+ ? "Mở lại ứng dụng để hoàn tất tải lên"
221
+ : shown.percent + "% - mở lại ứng dụng để hoàn tất tải lên";
222
+ } else {
223
+ text = indeterminate
224
+ ? "Đang nén video"
225
+ : shown.percent + "% - đang nén";
226
+ }
227
+
56
228
  Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
57
- .setContentTitle("Đang nén video")
58
- .setContentText("Tiến trình đang chạy ngầm...")
59
- .setSmallIcon(android.R.drawable.ic_popup_sync)
229
+ .setContentTitle(title)
230
+ .setContentText(text)
231
+ .setSmallIcon(resolveSmallIcon())
232
+ .setProgress(100, indeterminate ? 0 : shown.percent, indeterminate)
233
+ .setOngoing(true)
234
+ // Không có nó, mỗi lần vẽ lại progress là một lần notification
235
+ // nhảy lên đầu danh sách và rung máy.
236
+ .setOnlyAlertOnce(true)
60
237
  .setPriority(NotificationCompat.PRIORITY_LOW)
238
+ .setContentIntent(buildOpenAppIntent())
239
+ .addAction(0, "Huỷ", buildCancelIntent())
61
240
  .build();
62
241
 
63
242
  try {
64
- // Google Play đòi mediaProcessing cho tác vụ nén video, nhưng type đó chỉ có
65
- // từ API 35. Trên API 34 hệ thống đọc manifest ra "type unknown",
66
- // startForeground() ném InvalidForegroundServiceTypeException và service
67
- // không bao giờ vào foreground - nén bị huỷ ngay khi user rời app. Nên chọn
68
- // type lúc chạy; AndroidManifest khai cả hai nên type nào cũng hợp lệ.
69
- if (Build.VERSION.SDK_INT >= 35) {
70
- startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING);
71
- } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
72
- startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
73
- } else {
74
- // Trước API 29 chưa có khái niệm foreground service type.
75
- startForeground(NOTIFICATION_ID, notification);
76
- }
243
+ ServiceCompat.startForeground(
244
+ this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
245
+ );
246
+ inForeground = true;
77
247
  } catch (Exception e) {
78
- // Nuốt lỗi ở đây để không giết app, nhưng phải log ra TAG riêng: nếu không,
79
- // service chạy nền âm thầm và không ai biết cho tới khi user báo nén hỏng.
80
- Log.e(TAG, "startForeground thất bại - service sẽ chạy nền, nén có thể bị huỷ", e);
248
+ // Nuốt để không giết app, nhưng log: service chạy nền âm thầm thì
249
+ // nén vẫn bị huỷ ngay khi user rời app và không ai biết tại sao.
250
+ Log.e(TAG, "startForeground thất bại - tiến trình có thể bị huỷ khi rời app", e);
81
251
  }
82
252
  }
83
253
 
84
- private void stopForegroundCompat() {
85
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
86
- stopForeground(STOP_FOREGROUND_REMOVE);
87
- } else {
88
- stopForeground(true);
89
- }
254
+ /**
255
+ * Icon brand của app nếu có drawable `ic_stat_video_compress`, nếu không thì
256
+ * icon hệ thống. Notification mang icon hệ thống trông như của Android chứ
257
+ * không phải của app - trên video demo gửi Play, đó là một điểm trừ.
258
+ */
259
+ private int resolveSmallIcon() {
260
+ int id = getResources().getIdentifier("ic_stat_video_compress", "drawable", getPackageName());
261
+ return id != 0 ? id : android.R.drawable.ic_popup_sync;
262
+ }
263
+
264
+ private PendingIntent buildCancelIntent() {
265
+ Intent intent = new Intent(this, VideoCompressionService.class).setAction(ACTION_CANCEL);
266
+ return PendingIntent.getService(
267
+ this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
268
+ );
90
269
  }
91
270
 
271
+ /** Bấm vào notification thì quay lại app, chứ không phải không có gì xảy ra. */
92
272
  @Nullable
273
+ private PendingIntent buildOpenAppIntent() {
274
+ Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
275
+ if (launch == null) return null;
276
+ launch.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
277
+ return PendingIntent.getActivity(
278
+ this, 2, launch, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
279
+ );
280
+ }
281
+
282
+ private void armWatchdog() {
283
+ handler.removeCallbacks(watchdog);
284
+ handler.postDelayed(watchdog, WATCHDOG_MS);
285
+ }
286
+
287
+ private void onWatchdogFired() {
288
+ Log.w(TAG, "Không có tiến trình nào trong " + (WATCHDOG_MS / 60000) + " phút - tự dừng");
289
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
290
+ stopEverything();
291
+ }
292
+
293
+ /**
294
+ * Android 15+ giới hạn dataSync tổng 6 giờ/24 giờ rồi gọi onTimeout. Phải
295
+ * dừng trong vài giây, nếu không app ăn ANR. destini-web đặt minSdk 35 nên
296
+ * mọi thiết bị đều ở trong diện này.
297
+ */
93
298
  @Override
94
- public IBinder onBind(Intent intent) {
95
- return null;
299
+ public void onTimeout(int startId, int fgsType) {
300
+ Log.w(TAG, "Hệ thống hết hạn foreground service (type " + fgsType + ") - dừng ngay");
301
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
302
+ stopEverything();
96
303
  }
97
304
 
98
- private void createNotificationChannel() {
99
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
100
- NotificationChannel serviceChannel = new NotificationChannel(
101
- CHANNEL_ID,
102
- "Video Compression Service Channel",
103
- NotificationManager.IMPORTANCE_LOW
104
- );
105
- NotificationManager manager = getSystemService(NotificationManager.class);
106
- if (manager != null) {
107
- manager.createNotificationChannel(serviceChannel);
108
- }
305
+ /** Overload của API 34, giữ lại cho thiết bị chạy Android 14. */
306
+ @Override
307
+ public void onTimeout(int startId) {
308
+ Log.w(TAG, "Hệ thống hết hạn foreground service - dừng ngay");
309
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
310
+ stopEverything();
311
+ }
312
+
313
+ private void stopEverything() {
314
+ handler.removeCallbacks(watchdog);
315
+ jobs.clear();
316
+ if (inForeground) {
317
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
318
+ inForeground = false;
109
319
  }
320
+ stopSelf();
321
+ }
322
+
323
+ @Nullable
324
+ @Override
325
+ public IBinder onBind(Intent intent) {
326
+ return null;
110
327
  }
111
328
  }
package/dist/docs.json CHANGED
@@ -53,6 +53,22 @@
53
53
  "complexTypes": [],
54
54
  "slug": "initialize"
55
55
  },
56
+ {
57
+ "name": "finishUpload",
58
+ "signature": "(options: { jobId: string; }) => Promise<void>",
59
+ "parameters": [
60
+ {
61
+ "name": "options",
62
+ "docs": "",
63
+ "type": "{ jobId: string; }"
64
+ }
65
+ ],
66
+ "returns": "Promise<void>",
67
+ "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.",
69
+ "complexTypes": [],
70
+ "slug": "finishupload"
71
+ },
56
72
  {
57
73
  "name": "addListener",
58
74
  "signature": "(eventName: 'onProgress', listenerFunc: (info: { status: string; percent?: number; }) => void) => Promise<PluginListenerHandle>",
@@ -75,6 +91,29 @@
75
91
  "PluginListenerHandle"
76
92
  ],
77
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-"
78
117
  }
79
118
  ],
80
119
  "properties": []
@@ -137,6 +176,13 @@
137
176
  "docs": "Names this compress so `cancel({ jobId })` can target it.\n\nEvery platform compresses one video at a time, so cancellation is global\nby nature. Two independent callers in the same app (say a comment queue\nand a notes queue) therefore need a way to say *which* job they mean —\nwithout one, a cancel from either kills whatever the other is running.",
138
177
  "complexTypes": [],
139
178
  "type": "string | undefined"
179
+ },
180
+ {
181
+ "name": "holdForUpload",
182
+ "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.",
184
+ "complexTypes": [],
185
+ "type": "boolean | undefined"
140
186
  }
141
187
  ]
142
188
  },
@@ -18,6 +18,18 @@ export interface CompressOptions {
18
18
  * without one, a cancel from either kills whatever the other is running.
19
19
  */
20
20
  jobId?: string;
21
+ /**
22
+ * Android: giữ foreground service sống sau khi nén xong, để phần upload do
23
+ * JS chạy tiếp theo không bị hệ thống giết khi user rời app.
24
+ *
25
+ * Bật cờ này là một lời hứa: caller PHẢI gọi {@link NativeVideoCompressorPlugin.finishUpload}
26
+ * khi upload kết thúc, kể cả ở nhánh lỗi. Không gọi thì service chỉ còn
27
+ * watchdog dọn hộ sau 15 phút, và trong 15 phút đó user nhìn thấy một
28
+ * notification nói rằng app đang làm việc trong khi nó không làm gì cả.
29
+ *
30
+ * Không có tác dụng trên web và iOS.
31
+ */
32
+ holdForUpload?: boolean;
21
33
  }
22
34
  export interface CompressResult {
23
35
  success: boolean;
@@ -49,8 +61,26 @@ export interface NativeVideoCompressorPlugin {
49
61
  success: boolean;
50
62
  message?: string;
51
63
  }>;
64
+ /**
65
+ * Báo rằng phần upload đi kèm `holdForUpload` đã xong (thành công hay thất
66
+ * bại đều phải gọi) để Android hạ foreground service xuống.
67
+ *
68
+ * No-op trên web và iOS.
69
+ */
70
+ finishUpload(options: {
71
+ jobId: string;
72
+ }): Promise<void>;
52
73
  addListener(eventName: 'onProgress', listenerFunc: (info: {
53
74
  status: string;
54
75
  percent?: number;
55
76
  }) => void): Promise<PluginListenerHandle>;
77
+ /**
78
+ * Android: user đã bấm "Huỷ" trên notification của foreground service.
79
+ *
80
+ * Pha nén được plugin tự huỷ trước khi event này bắn, nhưng pha upload chạy
81
+ * trong JS nên không ai dừng hộ được - caller phải tự abort request đang
82
+ * chạy. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ, và Play đánh
83
+ * trượt tiêu chí "can be terminated or stopped by the user".
84
+ */
85
+ addListener(eventName: 'onCancelRequested', listenerFunc: () => void): Promise<PluginListenerHandle>;
56
86
  }
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAW,CAAC","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * `code` on the error `compressVideo` rejects with when `cancel()` stopped it.\n * Callers should treat it as \"the user asked for this\" — no error toast, no\n * retry prompt — and tell it apart from a genuine compression failure.\n */\nexport const CANCELLED = 'CANCELLED';\n\nexport interface CompressOptions {\n sourcePath: string;\n destPath?: string;\n quality?: 'VERY_HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY_LOW' | '360P';\n /**\n * Names this compress so `cancel({ jobId })` can target it.\n *\n * Every platform compresses one video at a time, so cancellation is global\n * by nature. Two independent callers in the same app (say a comment queue\n * and a notes queue) therefore need a way to say *which* job they mean —\n * without one, a cancel from either kills whatever the other is running.\n */\n jobId?: string;\n}\n\nexport interface CompressResult {\n success: boolean;\n destPath: string;\n}\n\nexport interface CancelOptions {\n /**\n * Only cancel if this is the job currently running. Omit to cancel whatever\n * is running, whoever started it.\n */\n jobId?: string;\n}\n\nexport interface CancelResult {\n /** False when nothing was running, or when a different job was. */\n cancelled: boolean;\n}\n\nexport interface NativeVideoCompressorPlugin {\n compressVideo(options: CompressOptions): Promise<CompressResult>;\n\n /**\n * Stop the compress that is running now. `compressVideo` then rejects with\n * code {@link CANCELLED} and its partial output is deleted.\n *\n * This is the only way to stop one: the work is synchronous WASM inside a\n * web worker (web) or an AVFoundation / LightCompressor pass on its own\n * thread (native), and none of them watch an AbortSignal.\n */\n cancel(options?: CancelOptions): Promise<CancelResult>;\n\n initialize(): Promise<{ success: boolean; message?: string }>;\n\n // Đã sửa lại chuẩn cho Capacitor mới nhất (chỉ trả về Promise)\n addListener(\n eventName: 'onProgress',\n listenerFunc: (info: { status: string; percent?: number }) => void,\n ): Promise<PluginListenerHandle>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAW,CAAC","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * `code` on the error `compressVideo` rejects with when `cancel()` stopped it.\n * Callers should treat it as \"the user asked for this\" — no error toast, no\n * retry prompt — and tell it apart from a genuine compression failure.\n */\nexport const CANCELLED = 'CANCELLED';\n\nexport interface CompressOptions {\n sourcePath: string;\n destPath?: string;\n quality?: 'VERY_HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY_LOW' | '360P';\n /**\n * Names this compress so `cancel({ jobId })` can target it.\n *\n * Every platform compresses one video at a time, so cancellation is global\n * by nature. Two independent callers in the same app (say a comment queue\n * and a notes queue) therefore need a way to say *which* job they mean —\n * without one, a cancel from either kills whatever the other is running.\n */\n jobId?: string;\n\n /**\n * Android: giữ foreground service sống sau khi nén xong, để phần upload do\n * JS chạy tiếp theo không bị hệ thống giết khi user rời app.\n *\n * Bật cờ này là một lời hứa: caller PHẢI gọi {@link NativeVideoCompressorPlugin.finishUpload}\n * khi upload kết thúc, kể cả ở nhánh lỗi. Không gọi thì service chỉ còn\n * watchdog dọn hộ sau 15 phút, và trong 15 phút đó user nhìn thấy một\n * notification nói rằng app đang làm việc trong khi nó không làm gì cả.\n *\n * Không có tác dụng trên web và iOS.\n */\n holdForUpload?: boolean;\n}\n\nexport interface CompressResult {\n success: boolean;\n destPath: string;\n}\n\nexport interface CancelOptions {\n /**\n * Only cancel if this is the job currently running. Omit to cancel whatever\n * is running, whoever started it.\n */\n jobId?: string;\n}\n\nexport interface CancelResult {\n /** False when nothing was running, or when a different job was. */\n cancelled: boolean;\n}\n\nexport interface NativeVideoCompressorPlugin {\n compressVideo(options: CompressOptions): Promise<CompressResult>;\n\n /**\n * Stop the compress that is running now. `compressVideo` then rejects with\n * code {@link CANCELLED} and its partial output is deleted.\n *\n * This is the only way to stop one: the work is synchronous WASM inside a\n * web worker (web) or an AVFoundation / LightCompressor pass on its own\n * thread (native), and none of them watch an AbortSignal.\n */\n cancel(options?: CancelOptions): Promise<CancelResult>;\n\n initialize(): Promise<{ success: boolean; message?: string }>;\n\n /**\n * Báo rằng phần upload đi kèm `holdForUpload` đã xong (thành công hay thất\n * bại đều phải gọi) để Android hạ foreground service xuống.\n *\n * No-op trên web và iOS.\n */\n finishUpload(options: { jobId: string }): Promise<void>;\n\n // Đã sửa lại chuẩn cho Capacitor mới nhất (chỉ trả về Promise)\n addListener(\n eventName: 'onProgress',\n listenerFunc: (info: { status: string; percent?: number }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Android: user đã bấm \"Huỷ\" trên notification của foreground service.\n *\n * Pha nén được plugin tự huỷ trước khi event này bắn, nhưng pha upload chạy\n * trong JS nên không ai dừng hộ được - caller phải tự abort request đang\n * chạy. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ, và Play đánh\n * trượt tiêu chí \"can be terminated or stopped by the user\".\n */\n addListener(\n eventName: 'onCancelRequested',\n listenerFunc: () => void,\n ): Promise<PluginListenerHandle>;\n}\n"]}
package/dist/esm/web.d.ts CHANGED
@@ -24,6 +24,11 @@ export declare class NativeVideoCompressorWeb extends WebPlugin implements Nativ
24
24
  * stop it — its cost is the loaded core, which is why the core URLs are
25
25
  * cached above.
26
26
  */
27
+ /**
28
+ * Chỉ Android mới có foreground service để mà hạ xuống. Hiện diện ở đây để
29
+ * caller gọi thẳng, không phải rẽ nhánh theo platform ở mọi call site.
30
+ */
31
+ finishUpload(): Promise<void>;
27
32
  cancel(options?: CancelOptions): Promise<CancelResult>;
28
33
  private fetchSourceBlob;
29
34
  private canUseWebCodecs;
package/dist/esm/web.js CHANGED
@@ -134,6 +134,13 @@ export class NativeVideoCompressorWeb extends WebPlugin {
134
134
  * stop it — its cost is the loaded core, which is why the core URLs are
135
135
  * cached above.
136
136
  */
137
+ /**
138
+ * Chỉ Android mới có foreground service để mà hạ xuống. Hiện diện ở đây để
139
+ * caller gọi thẳng, không phải rẽ nhánh theo platform ở mọi call site.
140
+ */
141
+ async finishUpload() {
142
+ return;
143
+ }
137
144
  async cancel(options) {
138
145
  var _a;
139
146
  if (!this.currentJobId)