android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java (30173 bytes)
1 package com.equwal.scansubread; 2 3 import android.app.Activity; 4 import android.content.ClipData; 5 import android.content.ComponentName; 6 import android.content.ContentResolver; 7 import android.content.Context; 8 import android.content.Intent; 9 import android.content.SharedPreferences; 10 import android.content.pm.PackageInfo; 11 import android.content.pm.PackageManager; 12 import android.content.pm.ResolveInfo; 13 import android.database.Cursor; 14 import android.net.Uri; 15 import android.os.Bundle; 16 import android.provider.OpenableColumns; 17 import android.provider.Settings; 18 import android.view.WindowManager; 19 import androidx.activity.result.ActivityResult; 20 import androidx.core.content.FileProvider; 21 import com.getcapacitor.JSArray; 22 import com.getcapacitor.JSObject; 23 import com.getcapacitor.Plugin; 24 import com.getcapacitor.PluginCall; 25 import com.getcapacitor.PluginMethod; 26 import com.getcapacitor.annotation.ActivityCallback; 27 import com.getcapacitor.annotation.CapacitorPlugin; 28 import java.io.ByteArrayOutputStream; 29 import java.io.File; 30 import java.io.FileInputStream; 31 import java.io.FileOutputStream; 32 import java.io.IOException; 33 import java.io.InputStream; 34 import java.io.OutputStream; 35 import java.nio.charset.StandardCharsets; 36 import java.util.ArrayList; 37 import java.util.List; 38 import java.util.function.Predicate; 39 40 /** 41 * The bridge to the SubRead suite. 42 * 43 * - The audio player, through the content provider of SubRead Overlay 44 * (`content://space.subread.overlay.player/state`). The reader has no 45 * notification access, so the overlay answers for it. 46 * - A dictionary app, through `Intent.ACTION_PROCESS_TEXT`: SubRead 47 * Dictionary, Takoboto, AnkiDroid and other apps in the text selection 48 * menu. The lookup resolves when the dictionary closes. 49 * - The subtitle maker, through the intent API of the SubRead app 50 * (`space.subread.app.action.ALIGN`). SubRead 0.10.0 and later also 51 * write the .srt into a result file of this app. 52 * - SubRead Anki, through `space.subread.anki.action.ADD`: a card for a 53 * word and its sentence. 54 * - The package manager: which apps of the suite are installed. SubRead 55 * Overlay opens from the reader, so that the user can give it 56 * notification access. 57 * - The screen, which stays on during read-along. 58 */ 59 @CapacitorPlugin(name = "SubRead") 60 public class SubReadPlugin extends Plugin { 61 62 /** The release build of SubRead Overlay first, then the debug build. */ 63 private static final String[] AUTHORITIES = { 64 "space.subread.overlay.player", 65 "space.subread.overlay.debug.player", 66 }; 67 private static final String COLUMN_STATE = "state"; 68 private static final String NO_OVERLAY = "error=no_overlay"; 69 70 private static final String OVERLAY_PACKAGE = "space.subread.overlay"; 71 private static final String OVERLAY_DEBUG_PACKAGE = "space.subread.overlay.debug"; 72 73 private static final String PREFS = "subread"; 74 private static final String PREF_DICTIONARY = "dictionary"; 75 76 private static final String SUBREAD_PACKAGE = "space.subread.app"; 77 private static final String ACTION_ALIGN = "space.subread.app.action.ALIGN"; 78 private static final String EXTRA_AUDIO = "space.subread.extra.AUDIO"; 79 private static final String EXTRA_BOOK = "space.subread.extra.BOOK"; 80 private static final String EXTRA_LANGUAGE = "space.subread.extra.LANGUAGE"; 81 private static final String EXTRA_CUES = "space.subread.extra.CUES"; 82 private static final String EXTRA_MATCH_RATE = "space.subread.extra.MATCH_RATE"; 83 private static final String EXTRA_ERROR = "space.subread.extra.ERROR"; 84 private static final String EXTRA_RESULT = "space.subread.extra.RESULT"; 85 86 private static final String SHARE_DIR = "share"; 87 88 /** The folder in filesDir for the result files of SubRead. See file_paths.xml. */ 89 private static final String RESULT_DIR = "subread"; 90 91 /** The release build of SubRead Anki first, then the debug build. */ 92 private static final String[] ANKI_PACKAGES = { "space.subread.anki", "space.subread.anki.debug" }; 93 private static final String ACTION_ANKI_ADD = "space.subread.anki.action.ADD"; 94 private static final String EXTRA_ANKI_WORD = "space.subread.anki.extra.WORD"; 95 private static final String EXTRA_ANKI_READING = "space.subread.anki.extra.READING"; 96 private static final String EXTRA_ANKI_SENTENCE = "space.subread.anki.extra.SENTENCE"; 97 private static final String EXTRA_ANKI_TEXT = "space.subread.anki.extra.TEXT"; 98 private static final String EXTRA_ANKI_SOURCE = "space.subread.anki.extra.SOURCE"; 99 private static final String EXTRA_ANKI_SHOW = "space.subread.anki.extra.SHOW"; 100 private static final String EXTRA_ANKI_NOTE_ID = "space.subread.anki.extra.NOTE_ID"; 101 102 /** The authority that answered last. It is tried first. */ 103 private String authority; 104 105 /** Tells when the dictionary or SubRead Anki is closed. */ 106 private final ReturnWatch returns = new ReturnWatch(); 107 108 @Override 109 protected void handleOnPause() { 110 returns.paused(); 111 } 112 113 @Override 114 protected void handleOnResume() { 115 returns.resumed(); 116 } 117 118 /** 119 * Starts an activity for a result. Returns null when the activity 120 * started, else the exception of Android: ActivityNotFoundException when 121 * no app takes the intent, SecurityException when this app may not start 122 * it, for example when the read grant of an audio Uri is gone after a 123 * restart. Capacitor gives an exception of a plugin method to its plugin 124 * thread, and the app stops. So each caller ends the call with an error. 125 * No result comes, so the return watch has nothing to end. 126 */ 127 private RuntimeException startForResult(PluginCall call, Intent intent, String callback) { 128 try { 129 startActivityForResult(call, intent, callback); 130 return null; 131 } catch (RuntimeException e) { 132 return e; 133 } 134 } 135 136 // --- The player, through SubRead Overlay --- 137 138 private List<String> candidates() { 139 List<String> list = new ArrayList<>(); 140 if (authority != null) list.add(authority); 141 for (String a : AUTHORITIES) if (!a.equals(authority)) list.add(a); 142 return list; 143 } 144 145 private static Uri stateUri(String authority) { 146 return Uri.parse("content://" + authority + "/state"); 147 } 148 149 /** The state line of the first overlay that answers, or `error=no_overlay`. */ 150 private String queryState() { 151 ContentResolver resolver = getContext().getContentResolver(); 152 for (String a : candidates()) { 153 // query returns null when no app has the provider. It throws when 154 // the provider is not visible to this app. 155 try (Cursor c = resolver.query(stateUri(a), null, null, null, null)) { 156 if (c != null && c.moveToFirst()) { 157 authority = a; 158 String line = c.getString(0); 159 return line == null ? NO_OVERLAY : line; 160 } 161 } catch (RuntimeException e) { 162 // Try the next authority. 163 } 164 } 165 return NO_OVERLAY; 166 } 167 168 /** Sends play, pause or seek. Returns the state line after the call. */ 169 private String callPlayer(String method, String arg) { 170 ContentResolver resolver = getContext().getContentResolver(); 171 for (String a : candidates()) { 172 try { 173 Bundle answer = resolver.call(stateUri(a), method, arg, null); 174 if (answer != null) { 175 authority = a; 176 String line = answer.getString(COLUMN_STATE); 177 return line == null ? NO_OVERLAY : line; 178 } 179 } catch (RuntimeException e) { 180 // Try the next authority. 181 } 182 } 183 return NO_OVERLAY; 184 } 185 186 private static JSObject lineObject(String line) { 187 JSObject ret = new JSObject(); 188 ret.put("line", line); 189 return ret; 190 } 191 192 @PluginMethod 193 public void playerState(PluginCall call) { 194 call.resolve(lineObject(queryState())); 195 } 196 197 @PluginMethod 198 public void play(PluginCall call) { 199 call.resolve(lineObject(callPlayer("play", null))); 200 } 201 202 @PluginMethod 203 public void pause(PluginCall call) { 204 call.resolve(lineObject(callPlayer("pause", null))); 205 } 206 207 @PluginMethod 208 public void seek(PluginCall call) { 209 Integer ms = call.getInt("ms"); 210 if (ms == null) { 211 call.reject("ms is required."); 212 return; 213 } 214 call.resolve(lineObject(callPlayer("seek", String.valueOf(Math.max(0, ms))))); 215 } 216 217 // --- The screen --- 218 219 /** 220 * Keeps the screen on, or lets it turn off again. During read-along the 221 * user does not touch the screen, so without this the screen turns off. 222 */ 223 @PluginMethod 224 public void keepAwake(PluginCall call) { 225 Boolean on = call.getBoolean("on"); 226 if (on == null) { 227 call.reject("on is required."); 228 return; 229 } 230 // Only the UI thread can change the flags of the window. 231 getActivity() 232 .runOnUiThread(() -> { 233 if (on) { 234 getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); 235 } else { 236 getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); 237 } 238 call.resolve(); 239 }); 240 } 241 242 // --- The dictionary, through ACTION_PROCESS_TEXT --- 243 244 private SharedPreferences prefs() { 245 return getContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); 246 } 247 248 /** The intent that each app with "process text" in its manifest takes. */ 249 private static Intent probe() { 250 return new Intent(Intent.ACTION_PROCESS_TEXT).setType("text/plain"); 251 } 252 253 @PluginMethod 254 public void lookup(PluginCall call) { 255 String text = call.getString("text"); 256 if (text == null) { 257 call.reject("text is required."); 258 return; 259 } 260 Intent send = probe() 261 .putExtra(Intent.EXTRA_PROCESS_TEXT, text) 262 .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true); 263 String chosen = prefs().getString(PREF_DICTIONARY, ""); 264 ComponentName component = chosen.isEmpty() ? null : ComponentName.unflattenFromString(chosen); 265 if (component != null) { 266 returns.launched(); 267 if (startForResult(call, new Intent(send).setComponent(component), "lookupResult") == null) return; 268 // The chosen app is gone, or it does not let this app start it. Ask. 269 prefs().edit().remove(PREF_DICTIONARY).apply(); 270 } 271 returns.launched(); 272 RuntimeException failed = startForResult(call, Intent.createChooser(send, null), "lookupResult"); 273 if (failed != null) call.reject("Cannot open the dictionary: " + failed.getMessage(), failed); 274 } 275 276 /** 277 * The dictionary gave its result. The lookup is over when the dictionary 278 * is closed. A dictionary in a new task gives its result at once, so the 279 * watch can hold the call until the user is back. 280 */ 281 @ActivityCallback 282 private void lookupResult(PluginCall call, ActivityResult result) { 283 if (call == null) return; 284 returns.result(() -> { 285 JSObject ret = new JSObject(); 286 ret.put("closed", true); 287 call.resolve(ret); 288 }); 289 } 290 291 @PluginMethod 292 public void dictionaries(PluginCall call) { 293 PackageManager pm = getContext().getPackageManager(); 294 JSArray apps = new JSArray(); 295 for (ResolveInfo info : pm.queryIntentActivities(probe(), PackageManager.MATCH_DEFAULT_ONLY)) { 296 if (info.activityInfo == null) continue; 297 JSObject app = new JSObject(); 298 app.put("label", String.valueOf(info.loadLabel(pm))); 299 app.put( 300 "component", 301 new ComponentName(info.activityInfo.packageName, info.activityInfo.name).flattenToString() 302 ); 303 apps.put(app); 304 } 305 JSObject ret = new JSObject(); 306 ret.put("apps", apps); 307 ret.put("chosen", prefs().getString(PREF_DICTIONARY, "")); 308 call.resolve(ret); 309 } 310 311 @PluginMethod 312 public void setDictionary(PluginCall call) { 313 String component = call.getString("component", ""); 314 prefs().edit().putString(PREF_DICTIONARY, component == null ? "" : component).apply(); 315 call.resolve(); 316 } 317 318 // --- The card maker, through SubRead Anki --- 319 320 /** Puts the text option `name` in the extra `extra`, when the caller gave it. */ 321 private static void putText(Intent intent, PluginCall call, String name, String extra) { 322 String value = call.getString(name); 323 if (value != null && !value.isEmpty()) intent.putExtra(extra, value); 324 } 325 326 /** 327 * Asks SubRead Anki to make a card: the release build, else the debug 328 * build. SubRead Anki needs a word or a text. 329 */ 330 @PluginMethod 331 public void ankiAdd(PluginCall call) { 332 Intent add = new Intent(ACTION_ANKI_ADD); 333 putText(add, call, "word", EXTRA_ANKI_WORD); 334 putText(add, call, "reading", EXTRA_ANKI_READING); 335 putText(add, call, "sentence", EXTRA_ANKI_SENTENCE); 336 putText(add, call, "text", EXTRA_ANKI_TEXT); 337 putText(add, call, "source", EXTRA_ANKI_SOURCE); 338 if (!add.hasExtra(EXTRA_ANKI_WORD) && !add.hasExtra(EXTRA_ANKI_TEXT)) { 339 call.reject("word or text is required."); 340 return; 341 } 342 Boolean show = call.getBoolean("show"); 343 if (show != null) add.putExtra(EXTRA_ANKI_SHOW, show.booleanValue()); 344 PackageManager pm = getContext().getPackageManager(); 345 String anki = firstInstalled(ANKI_PACKAGES, name -> new Intent(add).setPackage(name).resolveActivity(pm) != null); 346 if (anki == null) { 347 call.resolve(errorObject("not_installed")); 348 return; 349 } 350 add.setPackage(anki); 351 returns.launched(); 352 if (startForResult(call, add, "ankiResult") != null) call.resolve(errorObject("cannot_start")); 353 } 354 355 /** SubRead Anki gave its result: the note is in Anki, or the user closed the card. */ 356 @ActivityCallback 357 private void ankiResult(PluginCall call, ActivityResult result) { 358 if (call == null) return; 359 returns.result(() -> { 360 JSObject ret = new JSObject(); 361 Intent data = result.getData(); 362 boolean added = result.getResultCode() == Activity.RESULT_OK; 363 ret.put("added", added); 364 if (added && data != null && data.hasExtra(EXTRA_ANKI_NOTE_ID)) { 365 ret.put("noteId", data.getLongExtra(EXTRA_ANKI_NOTE_ID, 0)); 366 } 367 call.resolve(ret); 368 }); 369 } 370 371 // --- The suite --- 372 373 /** The first of `packages` that is installed, or null when none is installed. */ 374 static String firstInstalled(String[] packages, Predicate<String> installed) { 375 for (String name : packages) if (installed.test(name)) return name; 376 return null; 377 } 378 379 /** The package info of an installed app, or null when the app is not installed. */ 380 private PackageInfo packageInfo(String name) { 381 try { 382 return getContext().getPackageManager().getPackageInfo(name, 0); 383 } catch (PackageManager.NameNotFoundException e) { 384 return null; 385 } 386 } 387 388 /** 389 * Tells which apps of the suite are installed, for a checklist. The 390 * packages are in the queries of the manifest, else Android hides them. 391 */ 392 @PluginMethod 393 public void suite(PluginCall call) { 394 // The version name of SubRead, or null when SubRead is not installed. 395 PackageInfo subread = packageInfo(SUBREAD_PACKAGE); 396 Object version = JSObject.NULL; 397 if (subread != null) version = subread.versionName == null ? "" : subread.versionName; 398 int dictionaries = 0; 399 PackageManager pm = getContext().getPackageManager(); 400 for (ResolveInfo info : pm.queryIntentActivities(probe(), PackageManager.MATCH_DEFAULT_ONLY)) { 401 // This app is not a dictionary. 402 if (info.activityInfo != null && !info.activityInfo.packageName.equals(getContext().getPackageName())) { 403 dictionaries++; 404 } 405 } 406 JSObject ret = new JSObject(); 407 ret.put("overlay", packageInfo(OVERLAY_PACKAGE) != null); 408 ret.put("overlayDebug", packageInfo(OVERLAY_DEBUG_PACKAGE) != null); 409 ret.put("subread", version); 410 ret.put("anki", firstInstalled(ANKI_PACKAGES, name -> packageInfo(name) != null) != null); 411 ret.put("dictionaries", dictionaries); 412 call.resolve(ret); 413 } 414 415 /** 416 * Opens SubRead Overlay, so that the user can give it notification 417 * access. Without SubRead Overlay, opens the notification access 418 * settings of Android. 419 */ 420 @PluginMethod 421 public void openOverlay(PluginCall call) { 422 PackageManager pm = getContext().getPackageManager(); 423 Intent open = pm.getLaunchIntentForPackage(OVERLAY_PACKAGE); 424 if (open == null) open = pm.getLaunchIntentForPackage(OVERLAY_DEBUG_PACKAGE); 425 String opened = "overlay"; 426 if (open == null) { 427 open = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS); 428 opened = "settings"; 429 } 430 try { 431 getActivity().startActivity(open); 432 } catch (RuntimeException e) { 433 // ActivityNotFoundException or SecurityException: see startForResult. 434 call.reject("Cannot open the " + opened + ".", e); 435 return; 436 } 437 JSObject ret = new JSObject(); 438 ret.put("opened", opened); 439 call.resolve(ret); 440 } 441 442 // --- The subtitle maker, through the SubRead app --- 443 444 /** Writes `text` to cacheDir/share/<name> and returns its FileProvider Uri. */ 445 private Uri shareFile(String name, String text) throws IOException { 446 File dir = new File(getContext().getCacheDir(), SHARE_DIR); 447 if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir); 448 File file = new File(dir, name); 449 try (OutputStream out = new FileOutputStream(file)) { 450 out.write(text.getBytes(StandardCharsets.UTF_8)); 451 } 452 return FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file); 453 } 454 455 private static JSObject errorObject(String error) { 456 JSObject ret = new JSObject(); 457 ret.put("error", error); 458 return ret; 459 } 460 461 /** 462 * A safe file name: the name keeps A-Z, a-z, 0-9, ".", "_" and "-", and 463 * each other character becomes "_". So the name cannot point to another 464 * folder. Null when no file can have the name. 465 */ 466 static String safeFileName(String name) { 467 String safe = name.replaceAll("[^A-Za-z0-9._-]", "_"); 468 if (safe.isEmpty() || safe.equals(".") || safe.equals("..") || safe.length() > 255) return null; 469 return safe; 470 } 471 472 /** The result file for `resultName`: filesDir/subread/<safe name>, or null. */ 473 private File resultFile(String resultName) { 474 String name = safeFileName(resultName); 475 return name == null ? null : new File(new File(getContext().getFilesDir(), RESULT_DIR), name); 476 } 477 478 /** 479 * Makes the folder of a result file and removes the result of an earlier 480 * job, so that pendingSubtitles gives only the result of the new job. 481 * Returns the FileProvider Uri of the file. 482 */ 483 private Uri clearResult(File file) throws IOException { 484 File dir = file.getParentFile(); 485 if (dir != null && !dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir); 486 if (file.exists() && !file.delete()) throw new IOException("Cannot remove " + file); 487 return FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file); 488 } 489 490 /** 491 * Asks SubRead for the .srt of the audio and the book text. The optional 492 * `resultName` names a result file, see pendingSubtitles. 493 */ 494 @PluginMethod 495 public void makeSubtitles(PluginCall call) { 496 String audio = call.getString("audio"); 497 String bookText = call.getString("bookText"); 498 String language = call.getString("language", "auto"); 499 String resultName = call.getString("resultName"); 500 if (audio == null || bookText == null) { 501 call.reject("audio and bookText are required."); 502 return; 503 } 504 File result = resultName == null ? null : resultFile(resultName); 505 if (resultName != null && result == null) { 506 call.reject("resultName is not a usable file name."); 507 return; 508 } 509 Uri bookUri; 510 try { 511 bookUri = shareFile("book.txt", bookText); 512 } catch (IOException e) { 513 call.reject("Cannot write the book text: " + e.getMessage(), e); 514 return; 515 } 516 Uri resultUri = null; 517 if (result != null) { 518 try { 519 resultUri = clearResult(result); 520 } catch (IOException e) { 521 call.reject("Cannot make the result file: " + e.getMessage(), e); 522 return; 523 } 524 } 525 // While SubRead runs, Capacitor keeps the options of this call two 526 // times in the saved state of the activity. That state has a limit of 527 // about 1 MB. With a large book text, the reader crashes when SubRead 528 // covers it. SubRead reads the book text from the file, so remove it. 529 call.getData().remove("bookText"); 530 Uri audioUri = Uri.parse(audio); 531 Intent ask = new Intent(ACTION_ALIGN) 532 .setPackage(SUBREAD_PACKAGE) 533 .putExtra(EXTRA_AUDIO, audioUri) 534 .putExtra(EXTRA_BOOK, bookUri) 535 .putExtra(EXTRA_LANGUAGE, language); 536 // SubRead must be able to read both files. 537 ClipData clip = ClipData.newRawUri("audio", audioUri); 538 clip.addItem(new ClipData.Item(bookUri)); 539 if (resultUri != null) { 540 // SubRead 0.10.0 and later write the .srt into this file before 541 // they answer, so the .srt is safe when Android stops the reader 542 // during the job. Older versions ignore the extra. 543 ask.putExtra(EXTRA_RESULT, resultUri); 544 clip.addItem(new ClipData.Item(resultUri)); 545 } 546 ask.setClipData(clip); 547 ask.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 548 if (ask.resolveActivity(getContext().getPackageManager()) == null) { 549 call.resolve(errorObject("not_installed")); 550 return; 551 } 552 if (resultUri != null) { 553 // SubRead must be able to write the result file. The intent flag 554 // FLAG_GRANT_WRITE_URI_PERMISSION asks for write access to each 555 // file of the clip, the audio file too. This app can only read the 556 // audio file, so startActivity would throw SecurityException. So 557 // only the result file gets write access. 558 getContext().grantUriPermission(SUBREAD_PACKAGE, resultUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 559 } 560 RuntimeException failed = startForResult(call, ask, "subtitlesResult"); 561 if (failed != null) { 562 // SubRead did not start, so it needs no write access. 563 if (resultUri != null) getContext().revokeUriPermission(resultUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 564 call.resolve(errorObject("cannot_start: " + failed.getMessage())); 565 } 566 } 567 568 /** 569 * Gives the .srt that SubRead wrote into the result file `resultName`, 570 * and removes the file. SubRead can finish while Android has stopped the 571 * reader. Then the activity result is lost, but the file is there. 572 * Resolves no srt when the file is not there or is empty. 573 */ 574 @PluginMethod 575 public void pendingSubtitles(PluginCall call) { 576 String resultName = call.getString("resultName"); 577 if (resultName == null) { 578 call.reject("resultName is required."); 579 return; 580 } 581 File file = resultFile(resultName); 582 if (file == null) { 583 call.reject("resultName is not a usable file name."); 584 return; 585 } 586 JSObject ret = new JSObject(); 587 if (file.isFile() && file.length() > 0) { 588 try (InputStream in = new FileInputStream(file)) { 589 ret.put("srt", readAll(in)); 590 } catch (IOException e) { 591 call.reject("Cannot read the result file: " + e.getMessage(), e); 592 return; 593 } 594 // The job is done, so SubRead needs no more write access. 595 Uri uri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file); 596 getContext().revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 597 // When the delete fails, the next call gives the same .srt again. 598 file.delete(); 599 } 600 call.resolve(ret); 601 } 602 603 private static String readAll(InputStream in) throws IOException { 604 ByteArrayOutputStream out = new ByteArrayOutputStream(); 605 byte[] buf = new byte[64 * 1024]; 606 int n; 607 while ((n = in.read(buf)) > 0) out.write(buf, 0, n); 608 return new String(out.toByteArray(), StandardCharsets.UTF_8); 609 } 610 611 @ActivityCallback 612 private void subtitlesResult(PluginCall call, ActivityResult result) { 613 if (call == null) return; 614 Intent data = result.getData(); 615 Uri srtUri = data == null ? null : data.getData(); 616 if (result.getResultCode() != Activity.RESULT_OK || srtUri == null) { 617 String error = data == null ? null : data.getStringExtra(EXTRA_ERROR); 618 call.resolve(errorObject(error == null ? "cancelled" : error)); 619 return; 620 } 621 // The grant ends with the calling activity: copy the file now, off the UI thread. 622 new Thread( 623 () -> { 624 try (InputStream in = getContext().getContentResolver().openInputStream(srtUri)) { 625 if (in == null) { 626 call.resolve(errorObject("cannot_read")); 627 return; 628 } 629 JSObject ret = new JSObject(); 630 ret.put("srt", readAll(in)); 631 ret.put("cues", data.getIntExtra(EXTRA_CUES, -1)); 632 ret.put("matchRate", data.getDoubleExtra(EXTRA_MATCH_RATE, -1)); 633 String language = data.getStringExtra(EXTRA_LANGUAGE); 634 ret.put("language", language == null ? JSObject.NULL : language); 635 call.resolve(ret); 636 } catch (IOException | RuntimeException e) { 637 call.resolve(errorObject("cannot_read: " + e.getMessage())); 638 } 639 }, 640 "SubReadSrt" 641 ) 642 .start(); 643 } 644 645 @PluginMethod 646 public void pickAudio(PluginCall call) { 647 Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); 648 intent.addCategory(Intent.CATEGORY_OPENABLE); 649 intent.setType("audio/*"); 650 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); 651 RuntimeException failed = startForResult(call, intent, "audioResult"); 652 if (failed != null) call.reject("Cannot open the file chooser: " + failed.getMessage(), failed); 653 } 654 655 @ActivityCallback 656 private void audioResult(PluginCall call, ActivityResult result) { 657 if (call == null) return; 658 Intent data = result.getData(); 659 Uri uri = data == null ? null : data.getData(); 660 if (result.getResultCode() != Activity.RESULT_OK || uri == null) { 661 call.reject("No audio file was picked."); 662 return; 663 } 664 ContentResolver resolver = getContext().getContentResolver(); 665 try { 666 // Keep the permission, so the Uri can be given to SubRead later. 667 resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); 668 } catch (SecurityException e) { 669 // Some providers give no persistable grant. The Uri still works now. 670 } 671 String name = uri.getLastPathSegment(); 672 try (Cursor c = resolver.query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) { 673 if (c != null && c.moveToFirst() && !c.isNull(0)) name = c.getString(0); 674 } catch (RuntimeException e) { 675 // Keep the path segment. 676 } 677 JSObject ret = new JSObject(); 678 ret.put("uri", uri.toString()); 679 ret.put("name", name == null ? "" : name); 680 call.resolve(ret); 681 } 682 683 /** Opens the share sheet with a text file, for example the finished .srt. */ 684 @PluginMethod 685 public void shareText(PluginCall call) { 686 String name = call.getString("name"); 687 String text = call.getString("text"); 688 if (name == null || text == null) { 689 call.reject("name and text are required."); 690 return; 691 } 692 try { 693 Uri uri = shareFile(name, text); 694 Intent send = new Intent(Intent.ACTION_SEND) 695 .setType("text/plain") 696 .putExtra(Intent.EXTRA_STREAM, uri) 697 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 698 // The chooser reads the file for its preview. createChooser gives 699 // the chooser the clip of this intent, with the read grant. Without 700 // a clip the chooser has no grant, and its preview gets a 701 // SecurityException. 702 send.setClipData(ClipData.newRawUri(name, uri)); 703 getActivity().startActivity(Intent.createChooser(send, name)); 704 call.resolve(); 705 } catch (IOException | RuntimeException e) { 706 call.reject("Cannot share: " + e.getMessage(), e); 707 } 708 } 709 }