|
19 | 19 | import com.getcapacitor.PluginMethod; |
20 | 20 | import com.getcapacitor.annotation.ActivityCallback; |
21 | 21 | 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; |
22 | 27 |
|
23 | 28 | @CapacitorPlugin(name = "UsbStorage") |
24 | 29 | public class UsbStoragePlugin extends Plugin { |
@@ -262,6 +267,220 @@ public void scanAvailableVideos(PluginCall call) { |
262 | 267 | } |
263 | 268 | } |
264 | 269 |
|
| 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 | + |
265 | 484 | @Override |
266 | 485 | protected void handleOnDestroy() { |
267 | 486 | if (mediaReceiver != null) { |
|
0 commit comments