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
|
+
}
|