Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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 }