capacitor-native-video-compressor 1.1.0 → 1.1.2

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
@@ -35,6 +35,36 @@ If you are reading videos from external storage, ensure you have the appropriate
35
35
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
36
36
  ```
37
37
 
38
+ #### JitPack repository (required)
39
+
40
+ The Android implementation depends on `LightCompressor`, which is published on JitPack.
41
+ A plugin's own `repositories` block does not propagate to the consuming app, so you must
42
+ add JitPack to your app's `android/build.gradle` — otherwise the build fails with
43
+ `Could not find com.github.AbedElazizShe:LightCompressor`:
44
+
45
+ ```gradle
46
+ allprojects {
47
+ repositories {
48
+ google()
49
+ mavenCentral()
50
+ maven { url 'https://jitpack.io' }
51
+ }
52
+ }
53
+ ```
54
+
55
+ #### compileSdk 35+ (required)
56
+
57
+ The compression foreground service declares `android:foregroundServiceType="mediaProcessing|dataSync"`.
58
+ `mediaProcessing` exists only from **API 35 (Android 15)**, so your app must build with
59
+ `compileSdkVersion` 35 or higher.
60
+
61
+ At runtime the service picks the type by API level: `mediaProcessing` on API 35+,
62
+ `dataSync` on API 29-34. Both types and both permissions are declared, so the plugin
63
+ runs correctly on Android 10 through 15+. Passing only `mediaProcessing` on API 34
64
+ makes the system read the type as unknown and `startForeground()` throws
65
+ `InvalidForegroundServiceTypeException` - the service never reaches the foreground
66
+ and compression is killed as soon as the user leaves the app.
67
+
38
68
  ## Web Configuration (Required)
39
69
 
40
70
  To use video compression on the Web platform, `FFmpeg.wasm` requires `SharedArrayBuffer` to function properly. You must configure your web server/framework to serve these specific HTTP headers:
@@ -196,7 +226,9 @@ The `quality` parameter determines the output resolution and compression ratio.
196
226
  * [`compressVideo(...)`](#compressvideo)
197
227
  * [`cancel(...)`](#cancel)
198
228
  * [`initialize()`](#initialize)
229
+ * [`finishUpload(...)`](#finishupload)
199
230
  * [`addListener('onProgress', ...)`](#addlisteneronprogress-)
231
+ * [`addListener('onCancelRequested', ...)`](#addlisteneroncancelrequested-)
200
232
  * [Interfaces](#interfaces)
201
233
 
202
234
  </docgen-index>
@@ -252,6 +284,24 @@ initialize() => Promise<{ success: boolean; message?: string; }>
252
284
  --------------------
253
285
 
254
286
 
287
+ ### finishUpload(...)
288
+
289
+ ```typescript
290
+ finishUpload(options: { jobId: string; }) => Promise<void>
291
+ ```
292
+
293
+ Báo rằng phần upload đi kèm `holdForUpload` đã xong (thành công hay thất
294
+ bại đều phải gọi) để Android hạ foreground service xuống.
295
+
296
+ No-op trên web và iOS.
297
+
298
+ | Param | Type |
299
+ | ------------- | ------------------------------- |
300
+ | **`options`** | <code>{ jobId: string; }</code> |
301
+
302
+ --------------------
303
+
304
+
255
305
  ### addListener('onProgress', ...)
256
306
 
257
307
  ```typescript
@@ -268,6 +318,29 @@ addListener(eventName: 'onProgress', listenerFunc: (info: { status: string; perc
268
318
  --------------------
269
319
 
270
320
 
321
+ ### addListener('onCancelRequested', ...)
322
+
323
+ ```typescript
324
+ addListener(eventName: 'onCancelRequested', listenerFunc: () => void) => Promise<PluginListenerHandle>
325
+ ```
326
+
327
+ Android: user đã bấm "Huỷ" trên notification của foreground service.
328
+
329
+ Pha nén được plugin tự huỷ trước khi event này bắn, nhưng pha upload chạy
330
+ trong JS nên không ai dừng hộ được - caller phải tự abort request đang
331
+ chạy. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ, và Play đánh
332
+ trượt tiêu chí "can be terminated or stopped by the user".
333
+
334
+ | Param | Type |
335
+ | ------------------ | -------------------------------- |
336
+ | **`eventName`** | <code>'onCancelRequested'</code> |
337
+ | **`listenerFunc`** | <code>() =&gt; void</code> |
338
+
339
+ **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
340
+
341
+ --------------------
342
+
343
+
271
344
  ### Interfaces
272
345
 
273
346
 
@@ -281,12 +354,13 @@ addListener(eventName: 'onProgress', listenerFunc: (info: { status: string; perc
281
354
 
282
355
  #### CompressOptions
283
356
 
284
- | Prop | Type | Description |
285
- | ---------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
- | **`sourcePath`** | <code>string</code> | |
287
- | **`destPath`** | <code>string</code> | |
288
- | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
289
- | **`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
+ | Prop | Type | Description |
358
+ | ------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
359
+ | **`sourcePath`** | <code>string</code> | |
360
+ | **`destPath`** | <code>string</code> | |
361
+ | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
362
+ | **`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. |
363
+ | **`holdForUpload`** | <code>boolean</code> | Android: giữ foreground service sống sau khi nén xong, để phần upload do JS chạy tiếp theo không bị hệ thống giết khi user rời app. Bật cờ này là một lời hứa: caller PHẢI gọi {@link NativeVideoCompressorPlugin.finishUpload} khi upload kết thúc, kể cả ở nhánh lỗi. Không gọi thì service chỉ còn watchdog dọn hộ sau 15 phút, và trong 15 phút đó user nhìn thấy một notification nói rằng app đang làm việc trong khi nó không làm gì cả. Không có tác dụng trên web và iOS. |
290
364
 
291
365
 
292
366
  #### CancelResult
@@ -1,6 +1,12 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
3
+ <!-- Chỉ dataSync, không khai mediaProcessing.
4
+ Hai type nghĩa là hai quyền phải biện minh riêng trong Play Console với
5
+ cùng một video demo, và mediaProcessing khó thuyết phục hơn hẳn. dataSync
6
+ phủ được cả hai pha: nén rồi tải lên là một thao tác đồng bộ dữ liệu liên
7
+ tục do user khởi tạo - đúng ví dụ "upload photo to the cloud" mà policy nêu. -->
3
8
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
9
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
4
10
 
5
11
  <application>
6
12
  <service
@@ -16,6 +16,7 @@ import com.getcapacitor.PluginCall;
16
16
  import com.getcapacitor.PluginMethod;
17
17
  import com.getcapacitor.annotation.CapacitorPlugin;
18
18
  import java.io.File;
19
+ import java.lang.ref.WeakReference;
19
20
  import java.util.Arrays;
20
21
  import java.util.Collections;
21
22
 
@@ -30,9 +31,52 @@ public class NativeVideoCompressorPlugin extends Plugin {
30
31
 
31
32
  private static final String STORAGE_SUBFOLDER = "compressed_videos";
32
33
 
34
+ /**
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.
38
+ */
39
+ private static WeakReference<NativeVideoCompressorPlugin> active = new WeakReference<>(null);
40
+
33
41
  /** The job compressVideo is running right now, for cancel({ jobId }). */
34
42
  private String currentJobId = null;
35
43
 
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
+
36
80
  /**
37
81
  * Nothing to do natively; LightCompressor needs no warm-up. Answered
38
82
  * rather than left unimplemented so a caller can await it unconditionally
@@ -73,6 +117,15 @@ public class NativeVideoCompressorPlugin extends Plugin {
73
117
  call.resolve(ret);
74
118
  }
75
119
 
120
+ @PluginMethod
121
+ public void finishUpload(PluginCall call) {
122
+ String jobId = call.getString("jobId");
123
+ if (jobId != null) {
124
+ sendToService(VideoCompressionService.ACTION_FINISH, jobId);
125
+ }
126
+ call.resolve();
127
+ }
128
+
76
129
  @PluginMethod
77
130
  public void compressVideo(PluginCall call) {
78
131
  String sourcePath = call.getString("sourcePath");
@@ -83,6 +136,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
83
136
  }
84
137
 
85
138
  String jobId = call.getString("jobId", "default");
139
+ boolean holdForUpload = Boolean.TRUE.equals(call.getBoolean("holdForUpload", false));
86
140
  synchronized (this) {
87
141
  currentJobId = jobId;
88
142
  }
@@ -140,14 +194,18 @@ public class NativeVideoCompressorPlugin extends Plugin {
140
194
 
141
195
  // Khởi động Foreground Service để giữ app luôn sống khi chạy ngầm
142
196
  Intent serviceIntent = new Intent(getContext(), VideoCompressionService.class);
143
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
144
- try {
197
+ serviceIntent.setAction(VideoCompressionService.ACTION_START);
198
+ serviceIntent.putExtra(VideoCompressionService.EXTRA_JOB_ID, jobId);
199
+ try {
200
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
145
201
  getContext().startForegroundService(serviceIntent);
146
- } catch (Exception e) {
202
+ } else {
147
203
  getContext().startService(serviceIntent);
148
204
  }
149
- } else {
150
- getContext().startService(serviceIntent);
205
+ } catch (Exception e) {
206
+ synchronized (this) { currentJobId = null; }
207
+ call.reject("Cannot start foreground video processing", e);
208
+ return;
151
209
  }
152
210
 
153
211
  // 4. Bắt đầu nén (Đã cập nhật List Uri và int index)
@@ -161,6 +219,11 @@ public class NativeVideoCompressorPlugin extends Plugin {
161
219
  new CompressionListener() {
162
220
  @Override
163
221
  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
+
164
227
  // Gửi % tiến độ về Javascript để vẽ thanh Progress
165
228
  JSObject ret = new JSObject();
166
229
  ret.put("status", "progress");
@@ -170,13 +233,13 @@ public class NativeVideoCompressorPlugin extends Plugin {
170
233
 
171
234
  @Override
172
235
  public void onFailure(int i, @NonNull String failureMessage) {
173
- finishJob(serviceIntent);
236
+ finishJob(jobId, false);
174
237
  call.reject("Nén thất bại: " + failureMessage);
175
238
  }
176
239
 
177
240
  @Override
178
241
  public void onSuccess(int i, long l, @Nullable String s) {
179
- finishJob(serviceIntent);
242
+ finishJob(jobId, holdForUpload);
180
243
  // Nén xong, trả kết quả đường dẫn file mới về cho JS
181
244
  JSObject ret = new JSObject();
182
245
  ret.put("success", true);
@@ -193,7 +256,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
193
256
 
194
257
  @Override
195
258
  public void onCancelled(int i) {
196
- finishJob(serviceIntent);
259
+ finishJob(jobId, false);
197
260
  // LightCompressor leaves whatever it had already written
198
261
  // behind; without this the cache keeps a partial mp4 per
199
262
  // cancel, and nothing ever collects them.
@@ -207,13 +270,35 @@ public class NativeVideoCompressorPlugin extends Plugin {
207
270
  }
208
271
 
209
272
  /** Every terminal callback ends here, so neither is ever left running. */
210
- private void finishJob(Intent serviceIntent) {
211
- getContext().stopService(serviceIntent);
273
+ private void finishJob(String jobId, boolean holdForUpload) {
274
+ sendToService(
275
+ holdForUpload ? VideoCompressionService.ACTION_UPLOAD : VideoCompressionService.ACTION_FINISH,
276
+ jobId
277
+ );
212
278
  synchronized (this) {
213
279
  currentJobId = null;
214
280
  }
215
281
  }
216
282
 
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) {
292
+ 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);
297
+ } catch (Exception e) {
298
+ Log.w("NativeVideoCompressor", "Could not send " + action + " to service", e);
299
+ }
300
+ }
301
+
217
302
  /** Best-effort: a partial file left in the cache is not worth failing over. */
218
303
  private void deleteOutput(String fileName) {
219
304
  try {
@@ -3,61 +3,317 @@ 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;
9
+ import android.content.pm.ServiceInfo;
8
10
  import android.os.Build;
11
+ import android.os.Handler;
9
12
  import android.os.IBinder;
13
+ import android.os.Looper;
14
+ import android.os.SystemClock;
15
+ import android.util.Log;
10
16
  import androidx.annotation.Nullable;
11
17
  import androidx.core.app.NotificationCompat;
18
+ import androidx.core.app.ServiceCompat;
19
+ import java.util.LinkedHashMap;
20
+ import java.util.Map;
12
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
+ */
13
35
  public class VideoCompressionService extends Service {
14
36
 
37
+ private static final String TAG = "VideoCompressionService";
15
38
  private static final String CHANNEL_ID = "VideoCompressionChannel";
16
39
  private static final int NOTIFICATION_ID = 1001;
17
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
+
50
+ /**
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".
56
+ *
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.
85
+ *
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.
91
+ */
92
+ @Nullable
93
+ private static volatile VideoCompressionService instance = null;
94
+
18
95
  @Override
19
96
  public void onCreate() {
20
97
  super.onCreate();
21
- 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 khi bạn dùng ứng dụng khác");
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();
22
144
  }
23
145
 
24
146
  @Override
25
147
  public int onStartCommand(Intent intent, int flags, int startId) {
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();
156
+
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;
163
+ }
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();
190
+ return START_NOT_STICKY;
191
+ }
192
+
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
+ String text;
211
+ if (indeterminate) {
212
+ text = "Đang tải lên, sẽ tiếp tục khi bạn dùng ứng dụng khác";
213
+ } else {
214
+ text = shown.percent + "% - "
215
+ + (shown.phase == Phase.UPLOADING ? "đang tải lên" : "đang nén")
216
+ + ", sẽ tiếp tục khi bạn dùng ứng dụng khác";
217
+ }
218
+
26
219
  Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
27
- .setContentTitle("Đang nén video")
28
- .setContentText("Tiến trình đang chạy ngầm...")
29
- .setSmallIcon(android.R.drawable.ic_popup_sync)
220
+ .setContentTitle(title)
221
+ .setContentText(text)
222
+ .setSmallIcon(resolveSmallIcon())
223
+ .setProgress(100, indeterminate ? 0 : shown.percent, indeterminate)
224
+ .setOngoing(true)
225
+ // Không có nó, mỗi lần vẽ lại progress là một lần notification
226
+ // nhảy lên đầu danh sách và rung máy.
227
+ .setOnlyAlertOnce(true)
30
228
  .setPriority(NotificationCompat.PRIORITY_LOW)
229
+ .setContentIntent(buildOpenAppIntent())
230
+ .addAction(0, "Huỷ", buildCancelIntent())
31
231
  .build();
32
232
 
33
- // Check for Android 14+ specific foreground service typing requirements if compiled against API 34+
34
- // In this case, we rely on the manifest declaration for dataSync.
35
233
  try {
36
- startForeground(NOTIFICATION_ID, notification);
234
+ ServiceCompat.startForeground(
235
+ this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
236
+ );
237
+ inForeground = true;
37
238
  } catch (Exception e) {
38
- e.printStackTrace();
239
+ // Nuốt để không giết app, nhưng log: service chạy nền âm thầm thì
240
+ // nén vẫn bị huỷ ngay khi user rời app và không ai biết tại sao.
241
+ Log.e(TAG, "startForeground thất bại - tiến trình có thể bị huỷ khi rời app", e);
39
242
  }
243
+ }
40
244
 
41
- return START_NOT_STICKY;
245
+ /**
246
+ * Icon brand của app nếu có drawable `ic_stat_video_compress`, nếu không thì
247
+ * icon hệ thống. Notification mang icon hệ thống trông như của Android chứ
248
+ * không phải của app - trên video demo gửi Play, đó là một điểm trừ.
249
+ */
250
+ private int resolveSmallIcon() {
251
+ int id = getResources().getIdentifier("ic_stat_video_compress", "drawable", getPackageName());
252
+ return id != 0 ? id : android.R.drawable.ic_popup_sync;
253
+ }
254
+
255
+ private PendingIntent buildCancelIntent() {
256
+ Intent intent = new Intent(this, VideoCompressionService.class).setAction(ACTION_CANCEL);
257
+ return PendingIntent.getService(
258
+ this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
259
+ );
42
260
  }
43
261
 
262
+ /** Bấm vào notification thì quay lại app, chứ không phải không có gì xảy ra. */
44
263
  @Nullable
264
+ private PendingIntent buildOpenAppIntent() {
265
+ Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
266
+ if (launch == null) return null;
267
+ launch.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
268
+ return PendingIntent.getActivity(
269
+ this, 2, launch, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
270
+ );
271
+ }
272
+
273
+ private void armWatchdog() {
274
+ handler.removeCallbacks(watchdog);
275
+ handler.postDelayed(watchdog, WATCHDOG_MS);
276
+ }
277
+
278
+ private void onWatchdogFired() {
279
+ Log.w(TAG, "Không có tiến trình nào trong " + (WATCHDOG_MS / 60000) + " phút - tự dừng");
280
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
281
+ stopEverything();
282
+ }
283
+
284
+ /**
285
+ * Android 15+ giới hạn dataSync tổng 6 giờ/24 giờ rồi gọi onTimeout. Phải
286
+ * dừng trong vài giây, nếu không app ăn ANR. destini-web đặt minSdk 35 nên
287
+ * mọi thiết bị đều ở trong diện này.
288
+ */
45
289
  @Override
46
- public IBinder onBind(Intent intent) {
47
- return null;
290
+ public void onTimeout(int startId, int fgsType) {
291
+ Log.w(TAG, "Hệ thống hết hạn foreground service (type " + fgsType + ") - dừng ngay");
292
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
293
+ stopEverything();
48
294
  }
49
295
 
50
- private void createNotificationChannel() {
51
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
52
- NotificationChannel serviceChannel = new NotificationChannel(
53
- CHANNEL_ID,
54
- "Video Compression Service Channel",
55
- NotificationManager.IMPORTANCE_LOW
56
- );
57
- NotificationManager manager = getSystemService(NotificationManager.class);
58
- if (manager != null) {
59
- manager.createNotificationChannel(serviceChannel);
60
- }
296
+ /** Overload của API 34, giữ lại cho thiết bị chạy Android 14. */
297
+ @Override
298
+ public void onTimeout(int startId) {
299
+ Log.w(TAG, "Hệ thống hết hạn foreground service - dừng ngay");
300
+ NativeVideoCompressorPlugin.cancelAllFromNotification();
301
+ stopEverything();
302
+ }
303
+
304
+ private void stopEverything() {
305
+ handler.removeCallbacks(watchdog);
306
+ jobs.clear();
307
+ if (inForeground) {
308
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
309
+ inForeground = false;
61
310
  }
311
+ stopSelf();
312
+ }
313
+
314
+ @Nullable
315
+ @Override
316
+ public IBinder onBind(Intent intent) {
317
+ return null;
62
318
  }
63
319
  }