Skip to content

Commit 649b918

Browse files
committed
pick what to copy locally
1 parent e7adac6 commit 649b918

9 files changed

Lines changed: 913 additions & 63 deletions

File tree

android/app/src/main/java/com/slbible/UsbStoragePlugin.java

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
import com.getcapacitor.PluginMethod;
2020
import com.getcapacitor.annotation.ActivityCallback;
2121
import com.getcapacitor.annotation.CapacitorPlugin;
22+
import java.io.File;
23+
import java.io.FileOutputStream;
24+
import java.io.InputStream;
25+
import java.io.OutputStream;
26+
import org.json.JSONObject;
2227

2328
@CapacitorPlugin(name = "UsbStorage")
2429
public class UsbStoragePlugin extends Plugin {
@@ -262,6 +267,220 @@ public void scanAvailableVideos(PluginCall call) {
262267
}
263268
}
264269

270+
/**
271+
* Copies specific {book, chapter} pairs from a playlist on the USB drive into
272+
* app-private external storage, mirroring the same path as copyUsbPlaylist.
273+
* Expects: treeUri, playlist, items: [{book, chapter}]
274+
* Returns: { filesCopied: N }
275+
*/
276+
@PluginMethod
277+
public void copyUsbChapters(PluginCall call) {
278+
String treeUriStr = call.getString("treeUri");
279+
String playlist = call.getString("playlist");
280+
JSArray items = call.getArray("items");
281+
282+
if (treeUriStr == null || playlist == null || items == null) {
283+
call.reject("Missing required parameters");
284+
return;
285+
}
286+
287+
new Thread(() -> {
288+
try {
289+
Uri treeUri = Uri.parse(treeUriStr);
290+
DocumentFile root = DocumentFile.fromTreeUri(getContext(), treeUri);
291+
if (root == null || !root.exists()) {
292+
call.reject("USB directory is not accessible");
293+
return;
294+
}
295+
DocumentFile playlistDir = root.findFile(playlist);
296+
if (playlistDir == null || !playlistDir.isDirectory()) {
297+
call.reject("Playlist folder not found: " + playlist);
298+
return;
299+
}
300+
301+
File destPlaylist = new File(getContext().getExternalFilesDir(null), playlist);
302+
int filesCopied = 0;
303+
304+
for (int i = 0; i < items.length(); i++) {
305+
try {
306+
JSONObject item = items.getJSONObject(i);
307+
if (item == null) continue;
308+
String book = item.getString("book");
309+
String chapter = item.getString("chapter");
310+
if (book == null || chapter == null) continue;
311+
312+
DocumentFile bookDir = playlistDir.findFile(book);
313+
if (bookDir == null || !bookDir.isDirectory()) continue;
314+
315+
DocumentFile videoFile = bookDir.findFile(chapter + ".mp4");
316+
if (videoFile == null || !videoFile.exists()) continue;
317+
318+
File destBook = new File(destPlaylist, book);
319+
if (!destBook.exists()) destBook.mkdirs();
320+
File destFile = new File(destBook, chapter + ".mp4");
321+
322+
InputStream in = null;
323+
OutputStream out = null;
324+
try {
325+
in = getContext().getContentResolver().openInputStream(videoFile.getUri());
326+
if (in == null) continue;
327+
out = new FileOutputStream(destFile);
328+
byte[] buf = new byte[65536];
329+
int len;
330+
while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
331+
filesCopied++;
332+
} catch (Exception e) {
333+
Log.w("UsbStorage", "Failed chapter " + book + "/" + chapter + ": " + e.getMessage());
334+
if (destFile.exists()) destFile.delete();
335+
} finally {
336+
if (in != null) try { in.close(); } catch (Exception ignored) {}
337+
if (out != null) try { out.close(); } catch (Exception ignored) {}
338+
}
339+
} catch (Exception e) {
340+
Log.w("UsbStorage", "Error at item " + i + ": " + e.getMessage());
341+
}
342+
}
343+
344+
JSObject ret = new JSObject();
345+
ret.put("filesCopied", filesCopied);
346+
call.resolve(ret);
347+
} catch (Exception e) {
348+
Log.e("UsbStorage", "copyUsbChapters error: " + e.getMessage());
349+
call.reject("Error copying chapters: " + e.getMessage());
350+
}
351+
}).start();
352+
}
353+
354+
/**
355+
* Checks whether a file was previously copied from USB via copyUsbPlaylist and,
356+
* if so, registers it in UsbVideoRegistry and returns a _capacitor_usb_ URL so
357+
* UsbWebViewClient can stream it with Range/206 support.
358+
* Expects: playlist, book, chapter (without .mp4)
359+
* Returns: { playableUrl: string }
360+
*/
361+
@PluginMethod
362+
public void getLocalCopyUrl(PluginCall call) {
363+
String playlist = call.getString("playlist");
364+
String book = call.getString("book");
365+
String chapter = call.getString("chapter");
366+
367+
if (playlist == null || book == null || chapter == null) {
368+
call.reject("Missing required parameters");
369+
return;
370+
}
371+
372+
// Must match the base used in copyUsbPlaylist: getExternalFilesDir(null).
373+
File videoFile = new File(new File(new File(getContext().getExternalFilesDir(null), playlist), book), chapter + ".mp4");
374+
375+
Log.d("UsbStorage", "getLocalCopyUrl: path=" + videoFile.getAbsolutePath() + " exists=" + videoFile.exists());
376+
377+
if (!videoFile.exists()) {
378+
call.reject("Local copy not found: " + videoFile.getAbsolutePath());
379+
return;
380+
}
381+
382+
// Register a file:// URI in the same registry used for USB content so
383+
// UsbWebViewClient handles streaming with full Range / 206 support.
384+
Uri fileUri = Uri.fromFile(videoFile);
385+
String token = UsbVideoRegistry.register(fileUri);
386+
String playableUrl = "https://localhost" + UsbWebViewClient.USB_PATH_PREFIX + token;
387+
Log.d("UsbStorage", "getLocalCopyUrl: " + playableUrl);
388+
389+
JSObject ret = new JSObject();
390+
ret.put("playableUrl", playableUrl);
391+
call.resolve(ret);
392+
}
393+
394+
/**
395+
* Copies all .mp4 files for a playlist from the USB drive into the app's
396+
* private internal storage (getFilesDir), mirroring the USB folder structure:
397+
* getFilesDir()/{playlist}/{book}/{chapter}.mp4
398+
*
399+
* Private internal storage needs no external-storage permission and is
400+
* unaffected by Android scoped-storage rules.
401+
* Runs on a background thread so it won't block the UI.
402+
* Expects: treeUri, playlist
403+
* Returns: { ok: true, filesCopied: N }
404+
*/
405+
@PluginMethod
406+
public void copyUsbPlaylist(PluginCall call) {
407+
String treeUriStr = call.getString("treeUri");
408+
String playlist = call.getString("playlist");
409+
410+
if (treeUriStr == null || playlist == null) {
411+
call.reject("Missing required parameters");
412+
return;
413+
}
414+
415+
new Thread(() -> {
416+
try {
417+
Uri treeUri = Uri.parse(treeUriStr);
418+
DocumentFile root = DocumentFile.fromTreeUri(getContext(), treeUri);
419+
if (root == null || !root.exists()) {
420+
call.reject("USB directory is not accessible");
421+
return;
422+
}
423+
424+
DocumentFile playlistDir = root.findFile(playlist);
425+
if (playlistDir == null || !playlistDir.isDirectory()) {
426+
call.reject("Playlist folder not found: " + playlist);
427+
return;
428+
}
429+
430+
// App-private external storage — no permission needed on any Android version.
431+
// getLocalCopyUrl uses the same base, so paths are guaranteed to match.
432+
File destBase = new File(getContext().getExternalFilesDir(null), playlist);
433+
int filesCopied = 0;
434+
435+
for (DocumentFile bookDir : playlistDir.listFiles()) {
436+
if (!bookDir.isDirectory()) continue;
437+
String bookName = bookDir.getName();
438+
if (bookName == null) continue;
439+
440+
File destBook = new File(destBase, bookName);
441+
if (!destBook.exists()) destBook.mkdirs();
442+
443+
for (DocumentFile videoFile : bookDir.listFiles()) {
444+
String name = videoFile.getName();
445+
if (name == null || !name.endsWith(".mp4")) continue;
446+
447+
File destFile = new File(destBook, name);
448+
InputStream in = null;
449+
OutputStream out = null;
450+
try {
451+
in = getContext().getContentResolver()
452+
.openInputStream(videoFile.getUri());
453+
if (in == null) continue;
454+
out = new FileOutputStream(destFile);
455+
byte[] buffer = new byte[65536]; // 64 KB chunks
456+
int len;
457+
while ((len = in.read(buffer)) != -1) {
458+
out.write(buffer, 0, len);
459+
}
460+
filesCopied++;
461+
Log.d("UsbStorage", "Copied: " + playlist + "/" + bookName + "/" + name);
462+
} catch (Exception e) {
463+
Log.w("UsbStorage", "Failed to copy " + name + ": " + e.getMessage());
464+
// Partial file — clean up so it won't be treated as complete.
465+
if (destFile.exists()) destFile.delete();
466+
} finally {
467+
if (in != null) try { in.close(); } catch (Exception ignored) {}
468+
if (out != null) try { out.close(); } catch (Exception ignored) {}
469+
}
470+
}
471+
}
472+
473+
JSObject ret = new JSObject();
474+
ret.put("ok", true);
475+
ret.put("filesCopied", filesCopied);
476+
call.resolve(ret);
477+
} catch (Exception e) {
478+
Log.e("UsbStorage", "copyUsbPlaylist error: " + e.getMessage());
479+
call.reject("Error copying playlist: " + e.getMessage());
480+
}
481+
}).start();
482+
}
483+
265484
@Override
266485
protected void handleOnDestroy() {
267486
if (mediaReceiver != null) {

android/app/src/main/java/com/slbible/UsbWebViewClient.java

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import android.webkit.WebView;
1212
import com.getcapacitor.Bridge;
1313
import com.getcapacitor.BridgeWebViewClient;
14+
import java.io.File;
1415
import java.io.FileInputStream;
1516
import java.io.IOException;
1617
import java.io.InputStream;
@@ -53,17 +54,22 @@ private WebResourceResponse streamUsbFile(WebResourceRequest request) {
5354
}
5455
Log.d("UsbWebViewClient", "streaming: " + contentUri);
5556

57+
boolean isFileUri = "file".equals(contentUri.getScheme());
5658
ContentResolver cr = bridge.getContext().getContentResolver();
5759

58-
// Resolve file size for range support
60+
// Resolve file size — local files use File.length(), SAF files use ContentResolver query
5961
long fileSize = -1;
60-
try (Cursor cursor = cr.query(contentUri, new String[]{OpenableColumns.SIZE}, null, null, null)) {
61-
if (cursor != null && cursor.moveToFirst()) {
62-
int col = cursor.getColumnIndex(OpenableColumns.SIZE);
63-
if (col >= 0) fileSize = cursor.getLong(col);
62+
if (isFileUri) {
63+
fileSize = new File(contentUri.getPath()).length();
64+
} else {
65+
try (Cursor cursor = cr.query(contentUri, new String[]{OpenableColumns.SIZE}, null, null, null)) {
66+
if (cursor != null && cursor.moveToFirst()) {
67+
int col = cursor.getColumnIndex(OpenableColumns.SIZE);
68+
if (col >= 0) fileSize = cursor.getLong(col);
69+
}
70+
} catch (Exception e) {
71+
Log.w("UsbWebViewClient", "could not query file size: " + e.getMessage());
6472
}
65-
} catch (Exception e) {
66-
Log.w("UsbWebViewClient", "could not query file size: " + e.getMessage());
6773
}
6874

6975
Map<String, String> headers = new HashMap<>();
@@ -74,6 +80,16 @@ private WebResourceResponse streamUsbFile(WebResourceRequest request) {
7480
String rangeHeader = request.getRequestHeaders().get("Range");
7581

7682
try {
83+
// Open a seekable FileInputStream — works for both local files and SAF descriptors
84+
FileInputStream fis;
85+
if (isFileUri) {
86+
fis = new FileInputStream(new File(contentUri.getPath()));
87+
} else {
88+
ParcelFileDescriptor pfd = cr.openFileDescriptor(contentUri, "r");
89+
if (pfd == null) return errorResponse();
90+
fis = new FileInputStream(pfd.getFileDescriptor());
91+
}
92+
7793
if (rangeHeader != null && fileSize > 0) {
7894
// Parse "bytes=start-end"
7995
String[] parts = rangeHeader.replace("bytes=", "").split("-");
@@ -85,22 +101,14 @@ private WebResourceResponse streamUsbFile(WebResourceRequest request) {
85101

86102
headers.put("Content-Range", "bytes " + start + "-" + end + "/" + fileSize);
87103
headers.put("Content-Length", String.valueOf(length));
88-
89-
// Use ParcelFileDescriptor for efficient seeking
90-
ParcelFileDescriptor pfd = cr.openFileDescriptor(contentUri, "r");
91-
if (pfd == null) return errorResponse();
92-
FileInputStream fis = new FileInputStream(pfd.getFileDescriptor());
93104
fis.getChannel().position(start);
94105

95106
Log.d("UsbWebViewClient", "206 range " + start + "-" + end + "/" + fileSize);
96107
return new WebResourceResponse("video/mp4", null, 206, "Partial Content", headers, fis);
97108
} else {
98-
InputStream is = cr.openInputStream(contentUri);
99-
if (is == null) return errorResponse();
100109
if (fileSize > 0) headers.put("Content-Length", String.valueOf(fileSize));
101-
102110
Log.d("UsbWebViewClient", "200 full file, size=" + fileSize);
103-
return new WebResourceResponse("video/mp4", null, 200, "OK", headers, is);
111+
return new WebResourceResponse("video/mp4", null, 200, "OK", headers, fis);
104112
}
105113
} catch (IOException e) {
106114
Log.e("UsbWebViewClient", "stream error: " + e.getMessage());

0 commit comments

Comments
 (0)