1 /* Communication module for Android terminals. -*- c-file-style: "GNU" -*-
2
3 Copyright (C) 2023 Free Software Foundation, Inc.
4
5 This file is part of GNU Emacs.
6
7 GNU Emacs is free software: you can redistribute it and/or modify
8 it under the terms of the GNU General Public License as published by
9 the Free Software Foundation, either version 3 of the License, or (at
10 your option) any later version.
11
12 GNU Emacs is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 GNU General Public License for more details.
16
17 You should have received a copy of the GNU General Public License
18 along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>. */
19
20 package org.gnu.emacs;
21
22 /* This class makes the Emacs server work reasonably on Android.
23
24 There is no way to make the Unix socket publicly available on
25 Android.
26
27 Instead, this activity tries to connect to the Emacs server, to
28 make it open files the system asks Emacs to open, and to emulate
29 some reasonable behavior when Emacs has not yet started.
30
31 First, Emacs registers itself as an application that can open text
32 and image files.
33
34 Then, when the user is asked to open a file and selects ``Emacs''
35 as the application that will open the file, the system pops up a
36 window, this activity, and calls the `onCreate' function.
37
38 `onCreate' then tries very to find the file name of the file that
39 was selected, and give it to emacsclient.
40
41 If emacsclient successfully opens the file, then this activity
42 starts EmacsActivity (to bring it on to the screen); otherwise, it
43 displays the output of emacsclient or any error message that occurs
44 and exits. */
45
46 import android.app.AlertDialog;
47 import android.app.Activity;
48
49 import android.content.ContentResolver;
50 import android.content.DialogInterface;
51 import android.content.Intent;
52
53 import android.net.Uri;
54
55 import android.os.Build;
56 import android.os.Bundle;
57 import android.os.ParcelFileDescriptor;
58
59 import android.util.Log;
60
61 import java.io.File;
62 import java.io.FileInputStream;
63 import java.io.FileNotFoundException;
64 import java.io.FileOutputStream;
65 import java.io.FileReader;
66 import java.io.IOException;
67 import java.io.InputStream;
68 import java.io.UnsupportedEncodingException;
69
70 public final class EmacsOpenActivity extends Activity
71 implements DialogInterface.OnClickListener,
72 DialogInterface.OnCancelListener
73 {
74 private static final String TAG = "EmacsOpenActivity";
75
76 /* The name of any file that should be opened as EmacsThread starts
77 Emacs. This is never cleared, even if EmacsOpenActivity is
78 started a second time, as EmacsThread only starts once. */
79 public static String fileToOpen;
80
81 /* Any currently focused EmacsOpenActivity. Used to show pop ups
82 while the activity is active and Emacs doesn't have permission to
83 display over other programs. */
84 public static EmacsOpenActivity currentActivity;
85
86 private class EmacsClientThread extends Thread
87 {
88 private ProcessBuilder builder;
89
90 public
91 EmacsClientThread (ProcessBuilder processBuilder)
92 {
93 builder = processBuilder;
94 }
95
96 @Override
97 public void
98 run ()
99 {
100 Process process;
101 InputStream error;
102 String errorText;
103
104 try
105 {
106 /* Start emacsclient. */
107 process = builder.start ();
108 process.waitFor ();
109
110 /* Now figure out whether or not starting the process was
111 successful. */
112 if (process.exitValue () == 0)
113 finishSuccess ();
114 else
115 finishFailure ("Error opening file", null);
116 }
117 catch (IOException exception)
118 {
119 finishFailure ("Internal error", exception.toString ());
120 }
121 catch (InterruptedException exception)
122 {
123 finishFailure ("Internal error", exception.toString ());
124 }
125 }
126 }
127
128 @Override
129 public void
130 onClick (DialogInterface dialog, int which)
131 {
132 finish ();
133 }
134
135 @Override
136 public void
137 onCancel (DialogInterface dialog)
138 {
139 finish ();
140 }
141
142 public String
143 readEmacsClientLog ()
144 {
145 File file, cache;
146 FileReader reader;
147 char[] buffer;
148 int rc;
149 StringBuilder builder;
150
151 /* Because the ProcessBuilder functions necessary to redirect
152 process output are not implemented on Android 7 and earlier,
153 print a generic error message. */
154
155 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
156 return ("This is likely because the Emacs server"
157 + " is not running, or because you did"
158 + " not grant Emacs permission to access"
159 + " external storage.");
160
161 cache = getCacheDir ();
162 file = new File (cache, "emacsclient.log");
163 builder = new StringBuilder ();
164 reader = null;
165
166 try
167 {
168 reader = new FileReader (file);
169 buffer = new char[2048];
170
171 while ((rc = reader.read (buffer, 0, 2048)) != -1)
172 builder.append (buffer, 0, rc);
173
174 reader.close ();
175 return builder.toString ();
176 }
177 catch (IOException exception)
178 {
179 /* Close the reader if it's already been opened. */
180
181 try
182 {
183 if (reader != null)
184 reader.close ();
185 }
186 catch (IOException e)
187 {
188 /* Not sure what to do here. */
189 }
190
191 return ("Couldn't read emacsclient.log: "
192 + exception.toString ());
193 }
194 }
195
196 private void
197 displayFailureDialog (String title, String text)
198 {
199 AlertDialog.Builder builder;
200 AlertDialog dialog;
201
202 builder = new AlertDialog.Builder (this);
203 dialog = builder.create ();
204 dialog.setTitle (title);
205
206 if (text == null)
207 /* Read in emacsclient.log instead. */
208 text = readEmacsClientLog ();
209
210 dialog.setMessage (text);
211 dialog.setButton (DialogInterface.BUTTON_POSITIVE, "OK", this);
212 dialog.setOnCancelListener (this);
213 dialog.show ();
214 }
215
216 /* Check that the specified FILE is readable. If Android 4.4 or
217 later is being used, return URI formatted into a `/content/' file
218 name.
219
220 If it is not, then copy the file in FD to a location in the
221 system cache directory and return the name of that file. */
222
223 private String
224 checkReadableOrCopy (String file, ParcelFileDescriptor fd,
225 Uri uri)
226 throws IOException, FileNotFoundException
227 {
228 File inFile;
229 FileOutputStream outStream;
230 InputStream stream;
231 byte buffer[];
232 int read;
233 String content;
234
235 Log.d (TAG, "checkReadableOrCopy: " + file);
236
237 inFile = new File (file);
238
239 if (inFile.canRead ())
240 return file;
241
242 Log.d (TAG, "checkReadableOrCopy: NO");
243
244 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
245 {
246 content = EmacsService.buildContentName (uri);
247 Log.d (TAG, "checkReadableOrCopy: " + content);
248 return content;
249 }
250
251 /* inFile is now the file being written to. */
252 inFile = new File (getCacheDir (), inFile.getName ());
253 buffer = new byte[4098];
254
255 /* Initialize both streams to NULL. */
256 outStream = null;
257 stream = null;
258
259 try
260 {
261 outStream = new FileOutputStream (inFile);
262 stream = new FileInputStream (fd.getFileDescriptor ());
263
264 while ((read = stream.read (buffer)) >= 0)
265 outStream.write (buffer, 0, read);
266 }
267 finally
268 {
269 /* Note that this does not close FD.
270
271 Keep in mind that execution is transferred to ``finally''
272 even if an exception happens inside the while loop
273 above. */
274
275 if (stream != null)
276 stream.close ();
277
278 if (outStream != null)
279 outStream.close ();
280 }
281
282 return inFile.getCanonicalPath ();
283 }
284
285 /* Finish this activity in response to emacsclient having
286 successfully opened a file.
287
288 In the main thread, close this window, and open a window
289 belonging to an Emacs frame. */
290
291 public void
292 finishSuccess ()
293 {
294 runOnUiThread (new Runnable () {
295 @Override
296 public void
297 run ()
298 {
299 Intent intent;
300
301 intent = new Intent (EmacsOpenActivity.this,
302 EmacsActivity.class);
303
304 /* This means only an existing frame will be displayed. */
305 intent.addFlags (Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
306 startActivity (intent);
307
308 EmacsOpenActivity.this.finish ();
309 }
310 });
311 }
312
313 /* Finish this activity after displaying a dialog associated with
314 failure to open a file.
315
316 Use TITLE as the title of the dialog. If TEXT is non-NULL,
317 display that text in the dialog. Otherwise, use the contents of
318 emacsclient.log in the cache directory instead, or describe why
319 that file cannot be read. */
320
321 public void
322 finishFailure (final String title, final String text)
323 {
324 runOnUiThread (new Runnable () {
325 @Override
326 public void
327 run ()
328 {
329 displayFailureDialog (title, text);
330 }
331 });
332 }
333
334 public void
335 startEmacsClient (String fileName)
336 {
337 String libDir;
338 ProcessBuilder builder;
339 Process process;
340 EmacsClientThread thread;
341 File file;
342 Intent intent;
343
344 /* If the Emacs service is not running, then start Emacs and make
345 it open this file. */
346
347 if (EmacsService.SERVICE == null)
348 {
349 fileToOpen = fileName;
350 intent = new Intent (EmacsOpenActivity.this,
351 EmacsActivity.class);
352 finish ();
353 startActivity (intent);
354 return;
355 }
356
357 libDir = EmacsService.getLibraryDirectory (this);
358 builder = new ProcessBuilder (libDir + "/libemacsclient.so",
359 fileName, "--reuse-frame",
360 "--timeout=10", "--no-wait");
361
362 /* Redirection is unfortunately not possible in Android 7 and
363 earlier. */
364
365 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
366 {
367 file = new File (getCacheDir (), "emacsclient.log");
368
369 /* Redirect standard error to a file so that errors can be
370 meaningfully reported. */
371
372 if (file.exists ())
373 file.delete ();
374
375 builder.redirectError (file);
376 }
377
378 /* Track process output in a new thread, since this is the UI
379 thread and doing so here can cause deadlocks when EmacsService
380 decides to wait for something. */
381
382 thread = new EmacsClientThread (builder);
383 thread.start ();
384 }
385
386 /* Run emacsclient to open the file specified in the Intent that
387 caused this activity to start.
388
389 Determine the name of the file corresponding to the URI specified
390 in that intent; then, run emacsclient and wait for it to finish.
391
392 Finally, display any error message, transfer the focus to an
393 Emacs frame, and finish the activity. */
394
395 @Override
396 public void
397 onCreate (Bundle savedInstanceState)
398 {
399 String action, fileName;
400 Intent intent;
401 Uri uri;
402 ContentResolver resolver;
403 ParcelFileDescriptor fd;
404 byte[] names;
405 String errorBlurb;
406
407 super.onCreate (savedInstanceState);
408
409 /* Obtain the intent that started Emacs. */
410 intent = getIntent ();
411 action = intent.getAction ();
412
413 if (action == null)
414 {
415 finish ();
416 return;
417 }
418
419 /* Now see if the action specified is supported by Emacs. */
420
421 if (action.equals ("android.intent.action.VIEW")
422 || action.equals ("android.intent.action.EDIT")
423 || action.equals ("android.intent.action.PICK"))
424 {
425 /* Obtain the URI of the action. */
426 uri = intent.getData ();
427
428 if (uri == null)
429 {
430 finish ();
431 return;
432 }
433
434 /* Now, try to get the file name. */
435
436 if (uri.getScheme ().equals ("file"))
437 fileName = uri.getPath ();
438 else
439 {
440 fileName = null;
441
442 if (uri.getScheme ().equals ("content"))
443 {
444 /* This is one of the annoying Android ``content''
445 URIs. Most of the time, there is actually an
446 underlying file, but it cannot be found without
447 opening the file and doing readlink on its file
448 descriptor in /proc/self/fd. */
449 resolver = getContentResolver ();
450 fd = null;
451
452 try
453 {
454 fd = resolver.openFileDescriptor (uri, "r");
455 names = EmacsNative.getProcName (fd.getFd ());
456
457 /* What is the right encoding here? */
458
459 if (names != null)
460 fileName = new String (names, "UTF-8");
461
462 fileName = checkReadableOrCopy (fileName, fd, uri);
463 }
464 catch (FileNotFoundException exception)
465 {
466 /* Do nothing. */
467 }
468 catch (IOException exception)
469 {
470 /* Do nothing. */
471 }
472
473 if (fd != null)
474 {
475 try
476 {
477 fd.close ();
478 }
479 catch (IOException exception)
480 {
481 /* Do nothing. */
482 }
483 }
484 }
485
486 if (fileName == null)
487 {
488 errorBlurb = ("The URI: " + uri + " could not be opened"
489 + ", as it does not encode file name inform"
490 + "ation.");
491 displayFailureDialog ("Error opening file", errorBlurb);
492 return;
493 }
494 }
495
496 /* And start emacsclient. Set `currentActivity' to this now.
497 Presumably, it will shortly become capable of displaying
498 dialogs. */
499 currentActivity = this;
500 startEmacsClient (fileName);
501 }
502 else
503 finish ();
504 }
505
506
507
508 @Override
509 public void
510 onDestroy ()
511 {
512 Log.d (TAG, "onDestroy: " + this);
513
514 /* Clear `currentActivity' if it refers to the activity being
515 destroyed. */
516
517 if (currentActivity == this)
518 this.currentActivity = null;
519
520 super.onDestroy ();
521 }
522
523 @Override
524 public void
525 onWindowFocusChanged (boolean isFocused)
526 {
527 Log.d (TAG, "onWindowFocusChanged: " + this + ", is now focused: "
528 + isFocused);
529
530 if (isFocused)
531 currentActivity = this;
532 else if (currentActivity == this)
533 currentActivity = null;
534
535 super.onWindowFocusChanged (isFocused);
536 }
537
538 @Override
539 public void
540 onPause ()
541 {
542 Log.d (TAG, "onPause: " + this);
543
544 /* XXX: clear currentActivity here as well; I don't know whether
545 or not onWindowFocusChanged is always called prior to this. */
546
547 if (currentActivity == this)
548 currentActivity = null;
549
550 super.onPause ();
551 }
552 }