1
Fork 0
mirror of git://git.sv.gnu.org/emacs.git synced 2026-01-02 10:11:05 -08:00

Allow quitting from Android content provider operations

* doc/emacs/android.texi (Android Document Providers): Say that
quitting is now possible.
* java/org/gnu/emacs/EmacsNative.java (EmacsNative): New
functions `safSyncAndReadInput', `safync' and `safPostRequest'.
* java/org/gnu/emacs/EmacsSafThread.java: New file.  Move
cancel-able SAF operations here.
* java/org/gnu/emacs/EmacsService.java (EmacsService): Allow
quitting from most SAF operations.
* src/androidvfs.c (android_saf_exception_check): Return EINTR
if OperationCanceledException is received.
(android_saf_stat, android_saf_access)
(android_document_id_from_name, android_saf_tree_opendir_1)
(android_saf_file_open): Don't allow reentrant calls from async
input handlers.
(NATIVE_NAME): Implement new synchronization primitives for JNI.
(android_vfs_init): Initialize new class.

* src/dired.c (open_directory): Handle EINTR from opendir.
* src/sysdep.c: Describe which operations may return EINTR on
Android.
This commit is contained in:
Po Lu 2023-07-28 15:19:37 +08:00
parent 03cf3bbb5c
commit 0709e03f88
7 changed files with 1127 additions and 483 deletions

View file

@ -0,0 +1,922 @@
/* Communication module for Android terminals. -*- c-file-style: "GNU" -*-
Copyright (C) 2023 Free Software Foundation, Inc.
This file is part of GNU Emacs.
GNU Emacs is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
GNU Emacs is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>. */
package org.gnu.emacs;
import android.content.ContentResolver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.CancellationSignal;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
/* Emacs runs long-running SAF operations on a second thread running
its own handler. These operations include opening files and
maintaining the path to document ID cache.
#if 0
Because Emacs paths are based on file display names, while Android
document identifiers have no discernible hierarchy of their own,
each file name lookup must carry out a repeated search for
directory documents with the names of all of the file name's
constituent components, where each iteration searches within the
directory document identified by the previous iteration.
A time limited cache tying components to document IDs is maintained
in order to speed up consecutive searches for file names sharing
the same components. Since listening for changes to each document
in the cache is prohibitively expensive, Emacs instead elects to
periodically remove entries that are older than a predetermined
amount of a time.
The cache is structured much like the directory trees whose
information it records, with each entry in the cache containing a
list of entries for their children. File name lookup consults the
cache and populates it with missing information simultaneously.
This is not yet implemented.
#endif
Long-running operations are also run on this thread for another
reason: Android uses special cancellation objects to terminate
ongoing IPC operations. However, the functions that perform these
operations block instead of providing mechanisms for the caller to
wait for their completion while also reading async input, as a
consequence of which the calling thread is unable to signal the
cancellation objects that it provides. Performing the blocking
operations in this auxiliary thread enables the main thread to wait
for completion itself, signaling the cancellation objects when it
deems necessary. */
public final class EmacsSafThread extends HandlerThread
{
/* The content resolver used by this thread. */
private final ContentResolver resolver;
/* Handler for this thread's main loop. */
private Handler handler;
/* File access mode constants. See `man 7 inode'. */
public static final int S_IRUSR = 0000400;
public static final int S_IWUSR = 0000200;
public static final int S_IFCHR = 0020000;
public static final int S_IFDIR = 0040000;
public static final int S_IFREG = 0100000;
public
EmacsSafThread (ContentResolver resolver)
{
super ("Document provider access thread");
this.resolver = resolver;
}
@Override
public void
start ()
{
super.start ();
/* Set up the handler after the thread starts. */
handler = new Handler (getLooper ());
}
/* ``Prototypes'' for nested functions that are run within the SAF
thread and accepts a cancellation signal. They differ in their
return types. */
private abstract class SafIntFunction
{
/* The ``throws Throwable'' here is a Java idiosyncracy that tells
the compiler to allow arbitrary error objects to be signaled
from within this function.
Later, runIntFunction will try to re-throw any error object
generated by this function in the Emacs thread, using a trick
to avoid the compiler requirement to expressly declare that an
error (and which types of errors) will be signaled. */
public abstract int runInt (CancellationSignal signal)
throws Throwable;
};
private abstract class SafObjectFunction
{
/* The ``throws Throwable'' here is a Java idiosyncracy that tells
the compiler to allow arbitrary error objects to be signaled
from within this function.
Later, runObjectFunction will try to re-throw any error object
generated by this function in the Emacs thread, using a trick
to avoid the compiler requirement to expressly declare that an
error (and which types of errors) will be signaled. */
public abstract Object runObject (CancellationSignal signal)
throws Throwable;
};
/* Functions that run cancel-able queries. These functions are
internally run within the SAF thread. */
/* Throw the specified EXCEPTION. The type template T is erased by
the compiler before the object is compiled, so the compiled code
simply throws EXCEPTION without the cast being verified.
T should be RuntimeException to obtain the desired effect of
throwing an exception without a compiler check. */
@SuppressWarnings("unchecked")
private static <T extends Throwable> void
throwException (Throwable exception)
throws T
{
throw (T) exception;
}
/* Run the given function (or rather, its `runInt' field) within the
SAF thread, waiting for it to complete.
If async input arrives in the meantime and sets Vquit_flag,
signal the cancellation signal supplied to that function.
Rethrow any exception thrown from that function, and return its
value otherwise. */
private int
runIntFunction (final SafIntFunction function)
{
final EmacsHolder<Object> result;
final CancellationSignal signal;
Throwable throwable;
result = new EmacsHolder<Object> ();
signal = new CancellationSignal ();
handler.post (new Runnable () {
@Override
public void
run ()
{
try
{
result.thing
= Integer.valueOf (function.runInt (signal));
}
catch (Throwable throwable)
{
result.thing = throwable;
}
EmacsNative.safPostRequest ();
}
});
if (EmacsNative.safSyncAndReadInput () != 0)
{
signal.cancel ();
/* Now wait for the function to finish. Either the signal has
arrived after the query took place, in which case it will
finish normally, or an OperationCanceledException will be
thrown. */
EmacsNative.safSync ();
}
if (result.thing instanceof Throwable)
{
throwable = (Throwable) result.thing;
EmacsSafThread.<RuntimeException>throwException (throwable);
}
return (Integer) result.thing;
}
/* Run the given function (or rather, its `runObject' field) within
the SAF thread, waiting for it to complete.
If async input arrives in the meantime and sets Vquit_flag,
signal the cancellation signal supplied to that function.
Rethrow any exception thrown from that function, and return its
value otherwise. */
private Object
runObjectFunction (final SafObjectFunction function)
{
final EmacsHolder<Object> result;
final CancellationSignal signal;
Throwable throwable;
result = new EmacsHolder<Object> ();
signal = new CancellationSignal ();
handler.post (new Runnable () {
@Override
public void
run ()
{
try
{
result.thing = function.runObject (signal);
}
catch (Throwable throwable)
{
result.thing = throwable;
}
EmacsNative.safPostRequest ();
}
});
if (EmacsNative.safSyncAndReadInput () != 0)
{
signal.cancel ();
/* Now wait for the function to finish. Either the signal has
arrived after the query took place, in which case it will
finish normally, or an OperationCanceledException will be
thrown. */
EmacsNative.safSync ();
}
if (result.thing instanceof Throwable)
{
throwable = (Throwable) result.thing;
EmacsSafThread.<RuntimeException>throwException (throwable);
}
return result.thing;
}
/* The crux of `documentIdFromName1', run within the SAF thread.
SIGNAL should be a cancellation signal run upon quitting. */
private int
documentIdFromName1 (String tree_uri, String name,
String[] id_return, CancellationSignal signal)
{
Uri uri, treeUri;
String id, type;
String[] components, projection;
Cursor cursor;
int column;
projection = new String[] {
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_MIME_TYPE,
};
/* Parse the URI identifying the tree first. */
uri = Uri.parse (tree_uri);
/* Now, split NAME into its individual components. */
components = name.split ("/");
/* Set id and type to the value at the root of the tree. */
type = id = null;
cursor = null;
/* For each component... */
try
{
for (String component : components)
{
/* Java split doesn't behave very much like strtok when it
comes to trailing and leading delimiters... */
if (component.isEmpty ())
continue;
/* Create the tree URI for URI from ID if it exists, or
the root otherwise. */
if (id == null)
id = DocumentsContract.getTreeDocumentId (uri);
treeUri
= DocumentsContract.buildChildDocumentsUriUsingTree (uri, id);
/* Look for a file in this directory by the name of
component. */
cursor = resolver.query (treeUri, projection,
(Document.COLUMN_DISPLAY_NAME
+ " = ?s"),
new String[] { component, },
null, signal);
if (cursor == null)
return -1;
while (true)
{
/* Even though the query selects for a specific
display name, some content providers nevertheless
return every file within the directory. */
if (!cursor.moveToNext ())
{
/* If the last component considered is a
directory... */
if ((type == null
|| type.equals (Document.MIME_TYPE_DIR))
/* ... and type and id currently represent the
penultimate component. */
&& component == components[components.length - 1])
{
/* The cursor is empty. In this case, return
-2 and the current document ID (belonging
to the previous component) in
ID_RETURN. */
id_return[0] = id;
/* But return -1 on the off chance that id is
null. */
if (id == null)
return -1;
return -2;
}
/* The last component found is not a directory, so
return -1. */
return -1;
}
/* So move CURSOR to a row with the right display
name. */
column = cursor.getColumnIndex (Document.COLUMN_DISPLAY_NAME);
if (column < 0)
continue;
name = cursor.getString (column);
/* Break out of the loop only once a matching
component is found. */
if (name.equals (component))
break;
}
/* Look for a column by the name of
COLUMN_DOCUMENT_ID. */
column = cursor.getColumnIndex (Document.COLUMN_DOCUMENT_ID);
if (column < 0)
return -1;
/* Now replace ID with the document ID. */
id = cursor.getString (column);
/* If this is the last component, be sure to initialize
the document type. */
if (component == components[components.length - 1])
{
column
= cursor.getColumnIndex (Document.COLUMN_MIME_TYPE);
if (column < 0)
return -1;
type = cursor.getString (column);
/* Type may be NULL depending on how the Cursor
returned is implemented. */
if (type == null)
return -1;
}
/* Now close the cursor. */
cursor.close ();
cursor = null;
/* ID may have become NULL if the data is in an invalid
format. */
if (id == null)
return -1;
}
}
finally
{
/* If an error is thrown within the block above, let
android_saf_exception_check handle it, but make sure the
cursor is closed. */
if (cursor != null)
cursor.close ();
}
/* Here, id is either NULL (meaning the same as TREE_URI), and
type is either NULL (in which case id should also be NULL) or
the MIME type of the file. */
/* First return the ID. */
if (id == null)
id_return[0] = DocumentsContract.getTreeDocumentId (uri);
else
id_return[0] = id;
/* Next, return whether or not this is a directory. */
if (type == null || type.equals (Document.MIME_TYPE_DIR))
return 1;
return 0;
}
/* Find the document ID of the file within TREE_URI designated by
NAME.
NAME is a ``file name'' comprised of the display names of
individual files. Each constituent component prior to the last
must name a directory file within TREE_URI.
Upon success, return 0 or 1 (contingent upon whether or not the
last component within NAME is a directory) and place the document
ID of the named file in ID_RETURN[0].
If the designated file can't be located, but each component of
NAME up to the last component can and is a directory, return -2
and the ID of the last component located in ID_RETURN[0].
If the designated file can't be located, return -1, or signal one
of OperationCanceledException, SecurityException,
FileNotFoundException, or UnsupportedOperationException. */
public int
documentIdFromName (final String tree_uri, final String name,
final String[] id_return)
{
return runIntFunction (new SafIntFunction () {
@Override
public int
runInt (CancellationSignal signal)
{
return documentIdFromName1 (tree_uri, name, id_return,
signal);
}
});
}
/* The bulk of `statDocument'. SIGNAL should be a cancelation
signal. */
private long[]
statDocument1 (String uri, String documentId,
CancellationSignal signal)
{
Uri uriObject;
String[] projection;
long[] stat;
int index;
long tem;
String tem1;
Cursor cursor;
uriObject = Uri.parse (uri);
if (documentId == null)
documentId = DocumentsContract.getTreeDocumentId (uriObject);
/* Create a document URI representing DOCUMENTID within URI's
authority. */
uriObject
= DocumentsContract.buildDocumentUriUsingTree (uriObject, documentId);
/* Now stat this document. */
projection = new String[] {
Document.COLUMN_FLAGS,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_SIZE,
};
cursor = resolver.query (uriObject, projection, null,
null, null, signal);
if (cursor == null)
return null;
if (!cursor.moveToFirst ())
{
cursor.close ();
return null;
}
/* Create the array of file status. */
stat = new long[3];
try
{
index = cursor.getColumnIndex (Document.COLUMN_FLAGS);
if (index < 0)
return null;
tem = cursor.getInt (index);
stat[0] |= S_IRUSR;
if ((tem & Document.FLAG_SUPPORTS_WRITE) != 0)
stat[0] |= S_IWUSR;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
&& (tem & Document.FLAG_VIRTUAL_DOCUMENT) != 0)
stat[0] |= S_IFCHR;
index = cursor.getColumnIndex (Document.COLUMN_SIZE);
if (index < 0)
return null;
if (cursor.isNull (index))
stat[1] = -1; /* The size is unknown. */
else
stat[1] = cursor.getLong (index);
index = cursor.getColumnIndex (Document.COLUMN_MIME_TYPE);
if (index < 0)
return null;
tem1 = cursor.getString (index);
/* Check if this is a directory file. */
if (tem1.equals (Document.MIME_TYPE_DIR)
/* Files shouldn't be specials and directories at the same
time, but Android doesn't forbid document providers
from returning this information. */
&& (stat[0] & S_IFCHR) == 0)
/* Since FLAG_SUPPORTS_WRITE doesn't apply to directories,
just assume they're writable. */
stat[0] |= S_IFDIR | S_IWUSR;
/* If this file is neither a character special nor a
directory, indicate that it's a regular file. */
if ((stat[0] & (S_IFDIR | S_IFCHR)) == 0)
stat[0] |= S_IFREG;
index = cursor.getColumnIndex (Document.COLUMN_LAST_MODIFIED);
if (index >= 0 && !cursor.isNull (index))
{
/* Content providers are allowed to not provide mtime. */
tem = cursor.getLong (index);
stat[2] = tem;
}
}
finally
{
cursor.close ();
}
return stat;
}
/* Return file status for the document designated by the given
DOCUMENTID and tree URI. If DOCUMENTID is NULL, use the document
ID in URI itself.
Value is null upon failure, or an array of longs [MODE, SIZE,
MTIM] upon success, where MODE contains the file type and access
modes of the file as in `struct stat', SIZE is the size of the
file in BYTES or -1 if not known, and MTIM is the time of the
last modification to this file in milliseconds since 00:00,
January 1st, 1970.
OperationCanceledException and other typical exceptions may be
signaled upon receiving async input or other errors. */
public long[]
statDocument (final String uri, final String documentId)
{
return (long[]) runObjectFunction (new SafObjectFunction () {
@Override
public Object
runObject (CancellationSignal signal)
{
return statDocument1 (uri, documentId, signal);
}
});
}
/* The bulk of `accessDocument'. SIGNAL should be a cancellation
signal. */
private int
accessDocument1 (String uri, String documentId, boolean writable,
CancellationSignal signal)
{
Uri uriObject;
String[] projection;
int tem, index;
String tem1;
Cursor cursor;
uriObject = Uri.parse (uri);
if (documentId == null)
documentId = DocumentsContract.getTreeDocumentId (uriObject);
/* Create a document URI representing DOCUMENTID within URI's
authority. */
uriObject
= DocumentsContract.buildDocumentUriUsingTree (uriObject, documentId);
/* Now stat this document. */
projection = new String[] {
Document.COLUMN_FLAGS,
Document.COLUMN_MIME_TYPE,
};
cursor = resolver.query (uriObject, projection, null,
null, null, signal);
if (cursor == null)
return -1;
try
{
if (!cursor.moveToFirst ())
return -1;
if (!writable)
return 0;
index = cursor.getColumnIndex (Document.COLUMN_MIME_TYPE);
if (index < 0)
return -3;
/* Get the type of this file to check if it's a directory. */
tem1 = cursor.getString (index);
/* Check if this is a directory file. */
if (tem1.equals (Document.MIME_TYPE_DIR))
{
/* If so, don't check for FLAG_SUPPORTS_WRITE.
Check for FLAG_DIR_SUPPORTS_CREATE instead. */
if (!writable)
return 0;
index = cursor.getColumnIndex (Document.COLUMN_FLAGS);
if (index < 0)
return -3;
tem = cursor.getInt (index);
if ((tem & Document.FLAG_DIR_SUPPORTS_CREATE) == 0)
return -3;
return 0;
}
index = cursor.getColumnIndex (Document.COLUMN_FLAGS);
if (index < 0)
return -3;
tem = cursor.getInt (index);
if (writable && (tem & Document.FLAG_SUPPORTS_WRITE) == 0)
return -3;
}
finally
{
/* Close the cursor if an exception occurs. */
cursor.close ();
}
return 0;
}
/* Find out whether Emacs has access to the document designated by
the specified DOCUMENTID within the tree URI. If DOCUMENTID is
NULL, use the document ID in URI itself.
If WRITABLE, also check that the file is writable, which is true
if it is either a directory or its flags contains
FLAG_SUPPORTS_WRITE.
Value is 0 if the file is accessible, and one of the following if
not:
-1, if the file does not exist.
-2, if WRITABLE and the file is not writable.
-3, upon any other error.
In addition, arbitrary runtime exceptions (such as
SecurityException or UnsupportedOperationException) may be
thrown. */
public int
accessDocument (final String uri, final String documentId,
final boolean writable)
{
return runIntFunction (new SafIntFunction () {
@Override
public int
runInt (CancellationSignal signal)
{
return accessDocument1 (uri, documentId, writable,
signal);
}
});
}
/* The crux of openDocumentDirectory. SIGNAL must be a cancellation
signal. */
private Cursor
openDocumentDirectory1 (String uri, String documentId,
CancellationSignal signal)
{
Uri uriObject;
Cursor cursor;
String projection[];
uriObject = Uri.parse (uri);
/* If documentId is not set, use the document ID of the tree URI
itself. */
if (documentId == null)
documentId = DocumentsContract.getTreeDocumentId (uriObject);
/* Build a URI representing each directory entry within
DOCUMENTID. */
uriObject
= DocumentsContract.buildChildDocumentsUriUsingTree (uriObject,
documentId);
projection = new String [] {
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
};
cursor = resolver.query (uriObject, projection, null, null,
null, signal);
/* Return the cursor. */
return cursor;
}
/* Open a cursor representing each entry within the directory
designated by the specified DOCUMENTID within the tree URI.
If DOCUMENTID is NULL, use the document ID within URI itself.
Value is NULL upon failure.
In addition, arbitrary runtime exceptions (such as
SecurityException or UnsupportedOperationException) may be
thrown. */
public Cursor
openDocumentDirectory (final String uri, final String documentId)
{
return (Cursor) runObjectFunction (new SafObjectFunction () {
@Override
public Object
runObject (CancellationSignal signal)
{
return openDocumentDirectory1 (uri, documentId, signal);
}
});
}
/* The crux of `openDocument'. SIGNAL must be a cancellation
signal. */
public ParcelFileDescriptor
openDocument1 (String uri, String documentId, boolean write,
boolean truncate, CancellationSignal signal)
throws Throwable
{
Uri treeUri, documentUri;
String mode;
ParcelFileDescriptor fileDescriptor;
treeUri = Uri.parse (uri);
/* documentId must be set for this request, since it doesn't make
sense to ``open'' the root of the directory tree. */
documentUri
= DocumentsContract.buildDocumentUriUsingTree (treeUri, documentId);
if (write || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
{
/* Select the mode used to open the file. `rw' means open
a stat-able file, while `rwt' means that and to
truncate the file as well. */
if (truncate)
mode = "rwt";
else
mode = "rw";
fileDescriptor
= resolver.openFileDescriptor (documentUri, mode,
signal);
}
else
{
/* Select the mode used to open the file. `openFile'
below means always open a stat-able file. */
if (truncate)
/* Invalid mode! */
return null;
else
mode = "r";
fileDescriptor = resolver.openFile (documentUri, mode,
signal);
}
return fileDescriptor;
}
/* Open a file descriptor for a file document designated by
DOCUMENTID within the document tree identified by URI. If
TRUNCATE and the document already exists, truncate its contents
before returning.
On Android 9.0 and earlier, always open the document in
``read-write'' mode; this instructs the document provider to
return a seekable file that is stored on disk and returns correct
file status.
Under newer versions of Android, open the document in a
non-writable mode if WRITE is false. This is possible because
these versions allow Emacs to explicitly request a seekable
on-disk file.
Value is NULL upon failure or a parcel file descriptor upon
success. Call `ParcelFileDescriptor.close' on this file
descriptor instead of using the `close' system call.
FileNotFoundException and/or SecurityException and/or
UnsupportedOperationException and/or OperationCanceledException
may be thrown upon failure. */
public ParcelFileDescriptor
openDocument (final String uri, final String documentId,
final boolean write, final boolean truncate)
{
Object tem;
tem = runObjectFunction (new SafObjectFunction () {
@Override
public Object
runObject (CancellationSignal signal)
throws Throwable
{
return openDocument1 (uri, documentId, write, truncate,
signal);
}
});
return (ParcelFileDescriptor) tem;
}
};