str-native-video-player 2.0.0 → 2.0.1

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,430 +1,430 @@
1
- package com.strtv.app;
2
-
3
- import android.content.Context;
4
- import android.util.Log;
5
-
6
- import com.getcapacitor.JSArray;
7
- import com.getcapacitor.JSObject;
8
- import com.getcapacitor.Logger;
9
-
10
- import org.json.JSONArray;
11
- import org.json.JSONObject;
12
-
13
- import java.io.*;
14
- import java.net.URL;
15
- import java.util.*;
16
- import java.util.concurrent.*;
17
- import javax.net.ssl.HttpsURLConnection;
18
-
19
- /**
20
- * STRTV - streaming + cache manager (Option 1 MVP)
21
- *
22
- * Behavior:
23
- * - getVideoFile(uuid, url): returns cached File if present (updates LRU). Does NOT download.
24
- * - downloadInBackground(uuid, url): downloads into a .tmp file -> rename to final, update LRU, enforce cache.
25
- *
26
- * Notes:
27
- * - Concurrent downloads for same uuid are deduplicated.
28
- * - LRU persisted via file lastModified timestamps and loaded on init.
29
- */
30
- public class STRTV {
31
-
32
- private static final String TAG = "STRTV";
33
- private final Context context;
34
- private final File storageDir;
35
-
36
- // In-memory LRU map: uuid -> lastAccessedTime (access-order LinkedHashMap)
37
- // Access to lruMap must be synchronized on lruLock.
38
- private final LinkedHashMap<String, Long> lruMap = new LinkedHashMap<>(16, 0.75f, true);
39
- private final Object lruLock = new Object();
40
-
41
- private long maxCacheBytes = 1024L * 1024L * 1024L; // 1 GB default cache size
42
-
43
-
44
-
45
- // Background download executor & tracking
46
- private final ExecutorService dlExecutor = Executors.newFixedThreadPool(2);
47
- private final ConcurrentHashMap<String, Future<?>> activeDownloads = new ConcurrentHashMap<>();
48
-
49
- public STRTV(Context context) {
50
- this.context = context;
51
- this.storageDir = new File(context.getFilesDir(), "strtv_videos");
52
-
53
- if (!storageDir.exists()) {
54
- boolean ok = storageDir.mkdirs();
55
- if (!ok) Log.w(TAG, "Failed to create storageDir: " + storageDir.getAbsolutePath());
56
- }
57
-
58
- // Populate LRU map from existing files (persisted lastModified)
59
- initializeLRU();
60
- }
61
-
62
- public String echo(String value) {
63
- Logger.info(TAG, value);
64
- return value;
65
- }
66
-
67
- // ------------------------------------------------------------
68
- // Public API used by plugin
69
- // ------------------------------------------------------------
70
-
71
- /**
72
- * Returns cached file if present; otherwise returns null.
73
- * DOES NOT attempt to download. Caller should call downloadInBackground(uuid, url) if needed.
74
- */
75
- public File getVideoFile(String uuid, String url) {
76
- if (uuid == null || uuid.isEmpty()) return null;
77
- File f = findLocalVideo(uuid);
78
- if (f != null) {
79
- updateLRU(uuid);
80
- return f;
81
- }
82
- return null;
83
- }
84
-
85
- public interface VideoDownloadCallback {
86
- void onDownloadComplete(File cachedFile);
87
- }
88
-
89
- /**
90
- * Request a background download. Non-blocking.
91
- * If a download for this uuid is already in progress, this is a no-op.
92
- */
93
- public void downloadInBackground(final String uuid, final String url, VideoDownloadCallback callback) {
94
- if (uuid == null || url == null) {
95
- Log.w(TAG, "downloadInBackground called with null uuid or url");
96
- return;
97
- }
98
-
99
- // Deduplicate concurrent downloads
100
- if (activeDownloads.containsKey(uuid)) {
101
- Log.i(TAG, "Download already active for uuid=" + uuid);
102
- return;
103
- }
104
-
105
- Future<?> future = dlExecutor.submit(() -> {
106
- try {
107
- Log.i(TAG, "Background download started: " + uuid + " -> " + url);
108
- File downloaded = streamAndCacheVideoSync(uuid, url);
109
- if (downloaded != null) {
110
- updateLRU(uuid);
111
- enforceCacheLimit();
112
- Log.i(TAG, "Background download finished: " + downloaded.getAbsolutePath());
113
- if (callback != null) {
114
- Log.i(TAG, "Calling download completion handler: ");
115
- callback.onDownloadComplete(downloaded);
116
- }
117
- } else {
118
- Log.w(TAG, "Background download returned null for uuid=" + uuid);
119
- }
120
- } catch (Exception e) {
121
- Log.e(TAG, "Background download failed for uuid=" + uuid + " : " + e.getMessage());
122
- // ensure partial file cleanup handled in streamAndCacheVideoSync
123
- } finally {
124
- activeDownloads.remove(uuid);
125
- }
126
- });
127
-
128
- // track active download
129
- activeDownloads.put(uuid, future);
130
- }
131
-
132
- /**
133
- * Preload syncronously or asynchronously? We choose to kick off background download and return quickly.
134
- */
135
- public boolean preloadVideo(String uuid, String url) {
136
- File f = findLocalVideo(uuid);
137
- if (f != null) {
138
- updateLRU(uuid);
139
- return true;
140
- }
141
- // Start background download, return immediately
142
- downloadInBackground(uuid, url, null);
143
- return true;
144
- }
145
-
146
- // ------------------------------------------------------------
147
- // Core download (synchronous helper used by background task)
148
- // ------------------------------------------------------------
149
-
150
- /**
151
- * Synchronously download url -> storageDir/uuid.<ext>.tmp then atomically rename to final file.
152
- * Returns final File on success, null on failure.
153
- *
154
- * This is intended to be called off the main thread.
155
- */
156
- private File streamAndCacheVideoSync(String uuid, String urlString) {
157
- String ext = getExtensionFromUrl(urlString);
158
- File tmpFile = new File(storageDir, uuid + ext + ".tmp");
159
- File outFile = new File(storageDir, uuid + ext);
160
-
161
- HttpsURLConnection conn = null;
162
- FileOutputStream fos = null;
163
- InputStream input = null;
164
-
165
- try {
166
- // If final file already exists (race), return it
167
- if (outFile.exists() && outFile.length() > 0) {
168
- Log.i(TAG, "Final file already exists, skipping download: " + outFile.getAbsolutePath());
169
- return outFile;
170
- }
171
-
172
- URL url = new URL(urlString);
173
- conn = (HttpsURLConnection) url.openConnection();
174
- conn.setConnectTimeout(15_000);
175
- conn.setReadTimeout(30_000);
176
- conn.connect();
177
-
178
- int code = conn.getResponseCode();
179
- if (code != HttpsURLConnection.HTTP_OK) {
180
- throw new IOException("HTTP " + code);
181
- }
182
-
183
- input = new BufferedInputStream(conn.getInputStream());
184
- fos = new FileOutputStream(tmpFile);
185
-
186
- byte[] buffer = new byte[64 * 1024]; // 64KB
187
- int read;
188
- long total = 0L;
189
- while ((read = input.read(buffer)) != -1) {
190
- fos.write(buffer, 0, read);
191
- total += read;
192
- }
193
- fos.getFD().sync();
194
- fos.close();
195
- fos = null;
196
-
197
- // Optional sanity check: minimal file size
198
- if (!tmpFile.exists() || tmpFile.length() == 0) {
199
- throw new IOException("Downloaded file empty");
200
- }
201
-
202
- // Atomically rename tmp -> final
203
- if (tmpFile.renameTo(outFile)) {
204
- // Ensure lastModified is set to now (for LRU)
205
- outFile.setLastModified(System.currentTimeMillis());
206
- Log.i(TAG, "Download saved: " + outFile.getAbsolutePath() + " (" + total + " bytes)");
207
- return outFile;
208
- } else {
209
- // Rename failed: attempt copy & delete fallback
210
- Log.w(TAG, "renameTo failed; attempting copy fallback");
211
- copyFile(tmpFile, outFile);
212
- tmpFile.delete();
213
- outFile.setLastModified(System.currentTimeMillis());
214
- return outFile;
215
- }
216
- } catch (Exception e) {
217
- Log.e(TAG, "streamAndCacheVideoSync error: " + e.getMessage());
218
- // cleanup partial file
219
- if (tmpFile.exists()) {
220
- boolean deleted = tmpFile.delete();
221
- if (!deleted) Log.w(TAG, "Failed to delete partial tmp file: " + tmpFile.getAbsolutePath());
222
- }
223
- return null;
224
- } finally {
225
- if (conn != null) conn.disconnect();
226
- try {
227
- if (fos != null) fos.close();
228
- } catch (IOException ignored) {}
229
- try {
230
- if (input != null) input.close();
231
- } catch (IOException ignored) {}
232
- }
233
- }
234
-
235
- private void copyFile(File src, File dst) throws IOException {
236
- try (FileInputStream fis = new FileInputStream(src);
237
- FileOutputStream fos = new FileOutputStream(dst)) {
238
- byte[] buf = new byte[64 * 1024];
239
- int r;
240
- while ((r = fis.read(buf)) != -1) {
241
- fos.write(buf, 0, r);
242
- }
243
- fos.getFD().sync();
244
- }
245
- }
246
-
247
- // ------------------------------------------------------------
248
- // LRU & Cache management
249
- // ------------------------------------------------------------
250
-
251
- private void initializeLRU() {
252
- File[] files = storageDir.listFiles();
253
- if (files == null) return;
254
-
255
- synchronized (lruLock) {
256
- lruMap.clear();
257
- for (File file : files) {
258
- String name = file.getName();
259
- if (!isVideoFilename(name)) continue;
260
- String uuid = name.substring(0, name.lastIndexOf('.'));
261
- lruMap.put(uuid, file.lastModified());
262
- }
263
- }
264
- }
265
-
266
- private void updateLRU(String uuid) {
267
- long now = System.currentTimeMillis();
268
- synchronized (lruLock) {
269
- lruMap.put(uuid, now); // LinkedHashMap in access-order: put moves to tail
270
- }
271
- // Persist via file timestamp if file exists
272
- File f = findLocalVideo(uuid);
273
- if (f != null) {
274
- f.setLastModified(now);
275
- }
276
- }
277
-
278
- public void setMaxCacheSize(long bytes) {
279
- this.maxCacheBytes = bytes;
280
- enforceCacheLimit();
281
- }
282
-
283
- /**
284
- * Evicts least-recently used files until cache <= maxCacheBytes.
285
- * Note: synchronize when iterating lruMap
286
- */
287
- private void enforceCacheLimit() {
288
- long total = calculateDirectorySize(storageDir);
289
- Log.i(TAG, "Total directory size: " + total);
290
-
291
-
292
- synchronized (lruLock) {
293
- Iterator<Map.Entry<String, Long>> iterator = lruMap.entrySet().iterator();
294
- while (total > maxCacheBytes && iterator.hasNext()) {
295
- String uuid = iterator.next().getKey();
296
- File f = findLocalVideo(uuid);
297
- if (f != null) {
298
- long len = f.length();
299
- if (f.delete()) {
300
- total -= len;
301
- iterator.remove();
302
- Log.i(TAG, "Evicted video due to cache limit: " + uuid);
303
- } else {
304
- // Could not delete; remove from LRU to avoid infinite loop
305
- iterator.remove();
306
- Log.w(TAG, "Failed to delete file when evicting: " + f.getAbsolutePath());
307
- }
308
- } else {
309
- iterator.remove(); // file missing; remove from LRU
310
- }
311
- }
312
- }
313
- }
314
-
315
- // ------------------------------------------------------------
316
- // Utilities & file ops
317
- // ------------------------------------------------------------
318
-
319
- private boolean isVideoFilename(String name) {
320
- String lower = name.toLowerCase();
321
- return lower.endsWith(".mp4") || lower.endsWith(".webm");
322
- }
323
-
324
- private File findLocalVideo(String uuid) {
325
- if (uuid == null || uuid.isEmpty()) return null;
326
- File mp4 = new File(storageDir, uuid + ".mp4");
327
- if (mp4.exists()) return mp4;
328
- File webm = new File(storageDir, uuid + ".webm");
329
- if (webm.exists()) return webm;
330
- return null;
331
- }
332
-
333
- public boolean evictVideos(JSONArray uuids) {
334
- boolean allDeleted = true;
335
- for (int i = 0; i < uuids.length(); i++) {
336
- try {
337
- String uuid = uuids.getString(i);
338
- File f = findLocalVideo(uuid);
339
- if (f != null && !f.delete()) allDeleted = false;
340
- synchronized (lruLock) {
341
- lruMap.remove(uuid);
342
- }
343
- } catch (Exception e) {
344
- allDeleted = false;
345
- }
346
- }
347
- return allDeleted;
348
- }
349
-
350
- public JSArray listCachedVideos() {
351
- JSArray arr = new JSArray();
352
- File[] files = storageDir.listFiles();
353
- if (files == null) return arr;
354
-
355
- for (File f : files) {
356
- String name = f.getName();
357
- if (!isVideoFilename(name)) continue;
358
- String uuid = name.substring(0, name.lastIndexOf('.'));
359
- JSONObject obj = new JSONObject();
360
- try {
361
- obj.put("uuid", uuid);
362
- obj.put("url", f.getAbsolutePath());
363
- obj.put("size_bytes", f.length());
364
- long lastAccessed;
365
- synchronized (lruLock) {
366
- lastAccessed = lruMap.getOrDefault(uuid, f.lastModified());
367
- }
368
- obj.put("last_accessed", lastAccessed);
369
- arr.put(obj);
370
- } catch (Exception ignored) {}
371
- }
372
-
373
- return arr;
374
- }
375
-
376
- public JSObject getStorageStats() {
377
- JSObject ret = new JSObject();
378
- long availableBytes = context.getFilesDir().getUsableSpace();
379
- long totalBytes = context.getFilesDir().getTotalSpace();
380
- long usedBytesVideos = calculateDirectorySize(storageDir);
381
- long usedBytesApp = calculateDirectorySize(context.getFilesDir());
382
-
383
- ret.put("available_bytes", availableBytes);
384
- ret.put("total_bytes", totalBytes);
385
- ret.put("used_bytes_videos", usedBytesVideos);
386
- ret.put("used_bytes_app", usedBytesApp);
387
-
388
- return ret;
389
- }
390
-
391
- private long calculateDirectorySize(File dir) {
392
- if (dir == null || !dir.exists()) return 0;
393
- if (dir.isFile()) return dir.length();
394
-
395
- long size = 0;
396
- File[] files = dir.listFiles();
397
- if (files != null) {
398
- for (File f : files) {
399
- size += calculateDirectorySize(f);
400
- }
401
- }
402
- return size;
403
- }
404
-
405
- private String getExtensionFromUrl(String url) {
406
- try {
407
- String lower = url.toLowerCase();
408
- if (lower.contains("?")) lower = lower.substring(0, lower.indexOf("?"));
409
- if (lower.endsWith(".mp4")) return ".mp4";
410
- if (lower.endsWith(".webm")) return ".webm";
411
- return ".mp4";
412
- } catch (Exception e) {
413
- return ".mp4";
414
- }
415
- }
416
-
417
- // Optional: call this to cancel an active background download
418
- public boolean cancelDownload(String uuid) {
419
- Future<?> f = activeDownloads.remove(uuid);
420
- if (f != null) {
421
- return f.cancel(true);
422
- }
423
- return false;
424
- }
425
-
426
- // Graceful shutdown (if needed)
427
- public void shutdown() {
428
- dlExecutor.shutdownNow();
429
- }
430
- }
1
+ package com.strtv.app;
2
+
3
+ import android.content.Context;
4
+ import android.util.Log;
5
+
6
+ import com.getcapacitor.JSArray;
7
+ import com.getcapacitor.JSObject;
8
+ import com.getcapacitor.Logger;
9
+
10
+ import org.json.JSONArray;
11
+ import org.json.JSONObject;
12
+
13
+ import java.io.*;
14
+ import java.net.URL;
15
+ import java.util.*;
16
+ import java.util.concurrent.*;
17
+ import javax.net.ssl.HttpsURLConnection;
18
+
19
+ /**
20
+ * STRTV - streaming + cache manager (Option 1 MVP)
21
+ *
22
+ * Behavior:
23
+ * - getVideoFile(uuid, url): returns cached File if present (updates LRU). Does NOT download.
24
+ * - downloadInBackground(uuid, url): downloads into a .tmp file -> rename to final, update LRU, enforce cache.
25
+ *
26
+ * Notes:
27
+ * - Concurrent downloads for same uuid are deduplicated.
28
+ * - LRU persisted via file lastModified timestamps and loaded on init.
29
+ */
30
+ public class STRTV {
31
+
32
+ private static final String TAG = "STRTV";
33
+ private final Context context;
34
+ private final File storageDir;
35
+
36
+ // In-memory LRU map: uuid -> lastAccessedTime (access-order LinkedHashMap)
37
+ // Access to lruMap must be synchronized on lruLock.
38
+ private final LinkedHashMap<String, Long> lruMap = new LinkedHashMap<>(16, 0.75f, true);
39
+ private final Object lruLock = new Object();
40
+
41
+ private long maxCacheBytes = 1024L * 1024L * 1024L; // 1 GB default cache size
42
+
43
+
44
+
45
+ // Background download executor & tracking
46
+ private final ExecutorService dlExecutor = Executors.newFixedThreadPool(2);
47
+ private final ConcurrentHashMap<String, Future<?>> activeDownloads = new ConcurrentHashMap<>();
48
+
49
+ public STRTV(Context context) {
50
+ this.context = context;
51
+ this.storageDir = new File(context.getFilesDir(), "strtv_videos");
52
+
53
+ if (!storageDir.exists()) {
54
+ boolean ok = storageDir.mkdirs();
55
+ if (!ok) Log.w(TAG, "Failed to create storageDir: " + storageDir.getAbsolutePath());
56
+ }
57
+
58
+ // Populate LRU map from existing files (persisted lastModified)
59
+ initializeLRU();
60
+ }
61
+
62
+ public String echo(String value) {
63
+ Logger.info(TAG, value);
64
+ return value;
65
+ }
66
+
67
+ // ------------------------------------------------------------
68
+ // Public API used by plugin
69
+ // ------------------------------------------------------------
70
+
71
+ /**
72
+ * Returns cached file if present; otherwise returns null.
73
+ * DOES NOT attempt to download. Caller should call downloadInBackground(uuid, url) if needed.
74
+ */
75
+ public File getVideoFile(String uuid, String url) {
76
+ if (uuid == null || uuid.isEmpty()) return null;
77
+ File f = findLocalVideo(uuid);
78
+ if (f != null) {
79
+ updateLRU(uuid);
80
+ return f;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ public interface VideoDownloadCallback {
86
+ void onDownloadComplete(File cachedFile);
87
+ }
88
+
89
+ /**
90
+ * Request a background download. Non-blocking.
91
+ * If a download for this uuid is already in progress, this is a no-op.
92
+ */
93
+ public void downloadInBackground(final String uuid, final String url, VideoDownloadCallback callback) {
94
+ if (uuid == null || url == null) {
95
+ Log.w(TAG, "downloadInBackground called with null uuid or url");
96
+ return;
97
+ }
98
+
99
+ // Deduplicate concurrent downloads
100
+ if (activeDownloads.containsKey(uuid)) {
101
+ Log.i(TAG, "Download already active for uuid=" + uuid);
102
+ return;
103
+ }
104
+
105
+ Future<?> future = dlExecutor.submit(() -> {
106
+ try {
107
+ Log.i(TAG, "Background download started: " + uuid + " -> " + url);
108
+ File downloaded = streamAndCacheVideoSync(uuid, url);
109
+ if (downloaded != null) {
110
+ updateLRU(uuid);
111
+ enforceCacheLimit();
112
+ Log.i(TAG, "Background download finished: " + downloaded.getAbsolutePath());
113
+ if (callback != null) {
114
+ Log.i(TAG, "Calling download completion handler: ");
115
+ callback.onDownloadComplete(downloaded);
116
+ }
117
+ } else {
118
+ Log.w(TAG, "Background download returned null for uuid=" + uuid);
119
+ }
120
+ } catch (Exception e) {
121
+ Log.e(TAG, "Background download failed for uuid=" + uuid + " : " + e.getMessage());
122
+ // ensure partial file cleanup handled in streamAndCacheVideoSync
123
+ } finally {
124
+ activeDownloads.remove(uuid);
125
+ }
126
+ });
127
+
128
+ // track active download
129
+ activeDownloads.put(uuid, future);
130
+ }
131
+
132
+ /**
133
+ * Preload syncronously or asynchronously? We choose to kick off background download and return quickly.
134
+ */
135
+ public boolean preloadVideo(String uuid, String url) {
136
+ File f = findLocalVideo(uuid);
137
+ if (f != null) {
138
+ updateLRU(uuid);
139
+ return true;
140
+ }
141
+ // Start background download, return immediately
142
+ downloadInBackground(uuid, url, null);
143
+ return true;
144
+ }
145
+
146
+ // ------------------------------------------------------------
147
+ // Core download (synchronous helper used by background task)
148
+ // ------------------------------------------------------------
149
+
150
+ /**
151
+ * Synchronously download url -> storageDir/uuid.<ext>.tmp then atomically rename to final file.
152
+ * Returns final File on success, null on failure.
153
+ *
154
+ * This is intended to be called off the main thread.
155
+ */
156
+ private File streamAndCacheVideoSync(String uuid, String urlString) {
157
+ String ext = getExtensionFromUrl(urlString);
158
+ File tmpFile = new File(storageDir, uuid + ext + ".tmp");
159
+ File outFile = new File(storageDir, uuid + ext);
160
+
161
+ HttpsURLConnection conn = null;
162
+ FileOutputStream fos = null;
163
+ InputStream input = null;
164
+
165
+ try {
166
+ // If final file already exists (race), return it
167
+ if (outFile.exists() && outFile.length() > 0) {
168
+ Log.i(TAG, "Final file already exists, skipping download: " + outFile.getAbsolutePath());
169
+ return outFile;
170
+ }
171
+
172
+ URL url = new URL(urlString);
173
+ conn = (HttpsURLConnection) url.openConnection();
174
+ conn.setConnectTimeout(15_000);
175
+ conn.setReadTimeout(30_000);
176
+ conn.connect();
177
+
178
+ int code = conn.getResponseCode();
179
+ if (code != HttpsURLConnection.HTTP_OK) {
180
+ throw new IOException("HTTP " + code);
181
+ }
182
+
183
+ input = new BufferedInputStream(conn.getInputStream());
184
+ fos = new FileOutputStream(tmpFile);
185
+
186
+ byte[] buffer = new byte[64 * 1024]; // 64KB
187
+ int read;
188
+ long total = 0L;
189
+ while ((read = input.read(buffer)) != -1) {
190
+ fos.write(buffer, 0, read);
191
+ total += read;
192
+ }
193
+ fos.getFD().sync();
194
+ fos.close();
195
+ fos = null;
196
+
197
+ // Optional sanity check: minimal file size
198
+ if (!tmpFile.exists() || tmpFile.length() == 0) {
199
+ throw new IOException("Downloaded file empty");
200
+ }
201
+
202
+ // Atomically rename tmp -> final
203
+ if (tmpFile.renameTo(outFile)) {
204
+ // Ensure lastModified is set to now (for LRU)
205
+ outFile.setLastModified(System.currentTimeMillis());
206
+ Log.i(TAG, "Download saved: " + outFile.getAbsolutePath() + " (" + total + " bytes)");
207
+ return outFile;
208
+ } else {
209
+ // Rename failed: attempt copy & delete fallback
210
+ Log.w(TAG, "renameTo failed; attempting copy fallback");
211
+ copyFile(tmpFile, outFile);
212
+ tmpFile.delete();
213
+ outFile.setLastModified(System.currentTimeMillis());
214
+ return outFile;
215
+ }
216
+ } catch (Exception e) {
217
+ Log.e(TAG, "streamAndCacheVideoSync error: " + e.getMessage());
218
+ // cleanup partial file
219
+ if (tmpFile.exists()) {
220
+ boolean deleted = tmpFile.delete();
221
+ if (!deleted) Log.w(TAG, "Failed to delete partial tmp file: " + tmpFile.getAbsolutePath());
222
+ }
223
+ return null;
224
+ } finally {
225
+ if (conn != null) conn.disconnect();
226
+ try {
227
+ if (fos != null) fos.close();
228
+ } catch (IOException ignored) {}
229
+ try {
230
+ if (input != null) input.close();
231
+ } catch (IOException ignored) {}
232
+ }
233
+ }
234
+
235
+ private void copyFile(File src, File dst) throws IOException {
236
+ try (FileInputStream fis = new FileInputStream(src);
237
+ FileOutputStream fos = new FileOutputStream(dst)) {
238
+ byte[] buf = new byte[64 * 1024];
239
+ int r;
240
+ while ((r = fis.read(buf)) != -1) {
241
+ fos.write(buf, 0, r);
242
+ }
243
+ fos.getFD().sync();
244
+ }
245
+ }
246
+
247
+ // ------------------------------------------------------------
248
+ // LRU & Cache management
249
+ // ------------------------------------------------------------
250
+
251
+ private void initializeLRU() {
252
+ File[] files = storageDir.listFiles();
253
+ if (files == null) return;
254
+
255
+ synchronized (lruLock) {
256
+ lruMap.clear();
257
+ for (File file : files) {
258
+ String name = file.getName();
259
+ if (!isVideoFilename(name)) continue;
260
+ String uuid = name.substring(0, name.lastIndexOf('.'));
261
+ lruMap.put(uuid, file.lastModified());
262
+ }
263
+ }
264
+ }
265
+
266
+ private void updateLRU(String uuid) {
267
+ long now = System.currentTimeMillis();
268
+ synchronized (lruLock) {
269
+ lruMap.put(uuid, now); // LinkedHashMap in access-order: put moves to tail
270
+ }
271
+ // Persist via file timestamp if file exists
272
+ File f = findLocalVideo(uuid);
273
+ if (f != null) {
274
+ f.setLastModified(now);
275
+ }
276
+ }
277
+
278
+ public void setMaxCacheSize(long bytes) {
279
+ this.maxCacheBytes = bytes;
280
+ enforceCacheLimit();
281
+ }
282
+
283
+ /**
284
+ * Evicts least-recently used files until cache <= maxCacheBytes.
285
+ * Note: synchronize when iterating lruMap
286
+ */
287
+ private void enforceCacheLimit() {
288
+ long total = calculateDirectorySize(storageDir);
289
+ Log.i(TAG, "Total directory size: " + total);
290
+
291
+
292
+ synchronized (lruLock) {
293
+ Iterator<Map.Entry<String, Long>> iterator = lruMap.entrySet().iterator();
294
+ while (total > maxCacheBytes && iterator.hasNext()) {
295
+ String uuid = iterator.next().getKey();
296
+ File f = findLocalVideo(uuid);
297
+ if (f != null) {
298
+ long len = f.length();
299
+ if (f.delete()) {
300
+ total -= len;
301
+ iterator.remove();
302
+ Log.i(TAG, "Evicted video due to cache limit: " + uuid);
303
+ } else {
304
+ // Could not delete; remove from LRU to avoid infinite loop
305
+ iterator.remove();
306
+ Log.w(TAG, "Failed to delete file when evicting: " + f.getAbsolutePath());
307
+ }
308
+ } else {
309
+ iterator.remove(); // file missing; remove from LRU
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ // ------------------------------------------------------------
316
+ // Utilities & file ops
317
+ // ------------------------------------------------------------
318
+
319
+ private boolean isVideoFilename(String name) {
320
+ String lower = name.toLowerCase();
321
+ return lower.endsWith(".mp4") || lower.endsWith(".webm");
322
+ }
323
+
324
+ private File findLocalVideo(String uuid) {
325
+ if (uuid == null || uuid.isEmpty()) return null;
326
+ File mp4 = new File(storageDir, uuid + ".mp4");
327
+ if (mp4.exists()) return mp4;
328
+ File webm = new File(storageDir, uuid + ".webm");
329
+ if (webm.exists()) return webm;
330
+ return null;
331
+ }
332
+
333
+ public boolean evictVideos(JSONArray uuids) {
334
+ boolean allDeleted = true;
335
+ for (int i = 0; i < uuids.length(); i++) {
336
+ try {
337
+ String uuid = uuids.getString(i);
338
+ File f = findLocalVideo(uuid);
339
+ if (f != null && !f.delete()) allDeleted = false;
340
+ synchronized (lruLock) {
341
+ lruMap.remove(uuid);
342
+ }
343
+ } catch (Exception e) {
344
+ allDeleted = false;
345
+ }
346
+ }
347
+ return allDeleted;
348
+ }
349
+
350
+ public JSArray listCachedVideos() {
351
+ JSArray arr = new JSArray();
352
+ File[] files = storageDir.listFiles();
353
+ if (files == null) return arr;
354
+
355
+ for (File f : files) {
356
+ String name = f.getName();
357
+ if (!isVideoFilename(name)) continue;
358
+ String uuid = name.substring(0, name.lastIndexOf('.'));
359
+ JSONObject obj = new JSONObject();
360
+ try {
361
+ obj.put("uuid", uuid);
362
+ obj.put("url", f.getAbsolutePath());
363
+ obj.put("size_bytes", f.length());
364
+ long lastAccessed;
365
+ synchronized (lruLock) {
366
+ lastAccessed = lruMap.getOrDefault(uuid, f.lastModified());
367
+ }
368
+ obj.put("last_accessed", lastAccessed);
369
+ arr.put(obj);
370
+ } catch (Exception ignored) {}
371
+ }
372
+
373
+ return arr;
374
+ }
375
+
376
+ public JSObject getStorageStats() {
377
+ JSObject ret = new JSObject();
378
+ long availableBytes = context.getFilesDir().getUsableSpace();
379
+ long totalBytes = context.getFilesDir().getTotalSpace();
380
+ long usedBytesVideos = calculateDirectorySize(storageDir);
381
+ long usedBytesApp = calculateDirectorySize(context.getFilesDir());
382
+
383
+ ret.put("available_bytes", availableBytes);
384
+ ret.put("total_bytes", totalBytes);
385
+ ret.put("used_bytes_videos", usedBytesVideos);
386
+ ret.put("used_bytes_app", usedBytesApp);
387
+
388
+ return ret;
389
+ }
390
+
391
+ private long calculateDirectorySize(File dir) {
392
+ if (dir == null || !dir.exists()) return 0;
393
+ if (dir.isFile()) return dir.length();
394
+
395
+ long size = 0;
396
+ File[] files = dir.listFiles();
397
+ if (files != null) {
398
+ for (File f : files) {
399
+ size += calculateDirectorySize(f);
400
+ }
401
+ }
402
+ return size;
403
+ }
404
+
405
+ private String getExtensionFromUrl(String url) {
406
+ try {
407
+ String lower = url.toLowerCase();
408
+ if (lower.contains("?")) lower = lower.substring(0, lower.indexOf("?"));
409
+ if (lower.endsWith(".mp4")) return ".mp4";
410
+ if (lower.endsWith(".webm")) return ".webm";
411
+ return ".mp4";
412
+ } catch (Exception e) {
413
+ return ".mp4";
414
+ }
415
+ }
416
+
417
+ // Optional: call this to cancel an active background download
418
+ public boolean cancelDownload(String uuid) {
419
+ Future<?> f = activeDownloads.remove(uuid);
420
+ if (f != null) {
421
+ return f.cancel(true);
422
+ }
423
+ return false;
424
+ }
425
+
426
+ // Graceful shutdown (if needed)
427
+ public void shutdown() {
428
+ dlExecutor.shutdownNow();
429
+ }
430
+ }