/* Communication module for Android terminals. -*- c-file-style: "GNU" -*- Copyright (C) 2023-2025 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 . */ package org.gnu.emacs; import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.io.FileNotFoundException; import java.io.IOException; 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.OperationCanceledException; import android.os.ParcelFileDescriptor; import android.os.SystemClock; import android.util.Log; 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. 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 split into two levels: the first caches the relationships between display names and document IDs, while the second caches individual document IDs and their contents (children, type, etc.) 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 { private static final String TAG = "EmacsSafThread"; /* The content resolver used by this thread. */ private final ContentResolver resolver; /* Map between tree URIs and the cache entry representing its toplevel directory. */ private final HashMap cacheToplevels; /* 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_IXUSR = 0000100; public static final int S_IFCHR = 0020000; public static final int S_IFDIR = 0040000; public static final int S_IFREG = 0100000; /* Number of seconds in between each attempt to prune the storage cache. */ public static final int CACHE_PRUNE_TIME = 10; /* Number of seconds after which an entry in the cache is to be considered invalid. */ public static final int CACHE_INVALID_TIME = 10; public EmacsSafThread (ContentResolver resolver) { super ("Document provider access thread"); this.resolver = resolver; this.cacheToplevels = new HashMap (); } @Override public void start () { super.start (); /* Set up the handler after the thread starts. */ handler = new Handler (getLooper ()); /* And start periodically pruning the cache. */ postPruneMessage (); } private static final class CacheToplevel { /* Map between document names and children. */ HashMap children; /* Map between document names and file status. */ HashMap statCache; /* Map between document IDs and cache items. */ HashMap idCache; }; private static final class StatCacheEntry { /* The time at which this cache entry was created. */ long time; /* Flags, size, and modification time of this file. */ long flags, size, mtime; /* Whether or not this file is a directory. */ boolean isDirectory; public StatCacheEntry () { time = SystemClock.uptimeMillis (); } public boolean isValid () { return ((SystemClock.uptimeMillis () - time) < CACHE_INVALID_TIME * 1000); } }; private static final class DocIdEntry { /* The document ID. */ String documentId; /* The time this entry was created. */ long time; public DocIdEntry () { time = SystemClock.uptimeMillis (); } /* Return a cache entry comprised of the state of the file identified by `documentId'. TREE is the URI of the tree containing this entry, and TOPLEVEL is the toplevel representing it. SIGNAL is a cancellation signal. RESOLVER is the content provider used to retrieve file information. Value is NULL if the file cannot be found. */ public CacheEntry getCacheEntry (ContentResolver resolver, Uri tree, CacheToplevel toplevel, CancellationSignal signal) { Uri uri; String[] projection; String type; Cursor cursor; int column; CacheEntry entry; /* Create a document URI representing DOCUMENTID within URI's authority. */ uri = DocumentsContract.buildDocumentUriUsingTree (tree, documentId); projection = new String[] { Document.COLUMN_MIME_TYPE, }; cursor = null; try { cursor = resolver.query (uri, projection, null, null, null, signal); if (!cursor.moveToFirst ()) return null; column = cursor.getColumnIndex (Document.COLUMN_MIME_TYPE); if (column < 0) return null; type = cursor.getString (column); if (type == null) return null; entry = new CacheEntry (); entry.type = type; toplevel.idCache.put (documentId, entry); return entry; } catch (OperationCanceledException e) { throw e; } catch (Throwable e) { return null; } finally { if (cursor != null) cursor.close (); } } public boolean isValid () { return ((SystemClock.uptimeMillis () - time) < CACHE_INVALID_TIME * 1000); } }; private static final class CacheEntry { /* The type of this document. */ String type; /* Map between document names and children. */ HashMap children; /* The time this entry was created. */ long time; public CacheEntry () { children = new HashMap (); time = SystemClock.uptimeMillis (); } public boolean isValid () { return ((SystemClock.uptimeMillis () - time) < CACHE_INVALID_TIME * 1000); } }; /* Create or return a toplevel for the given tree URI. */ private CacheToplevel getCache (Uri uri) { CacheToplevel toplevel; toplevel = cacheToplevels.get (uri); if (toplevel != null) return toplevel; toplevel = new CacheToplevel (); toplevel.children = new HashMap (); toplevel.statCache = new HashMap (); toplevel.idCache = new HashMap (); cacheToplevels.put (uri, toplevel); return toplevel; } /* Remove each cache entry within COLLECTION older than CACHE_INVALID_TIME. */ private void pruneCache1 (Collection collection) { Iterator iter; DocIdEntry tem; iter = collection.iterator (); while (iter.hasNext ()) { /* Get the cache entry. */ tem = iter.next (); /* If it's not valid anymore, remove it. Iterating over a collection whose contents are being removed is undefined unless the removal is performed using the iterator's own `remove' function, so tem.remove cannot be used here. */ if (tem.isValid ()) continue; iter.remove (); } } /* Remove every entry older than CACHE_INVALID_TIME from each toplevel inside `cachedToplevels'. */ private void pruneCache () { Iterator iter; Iterator statIter; CacheEntry tem; StatCacheEntry stat; for (CacheToplevel toplevel : cacheToplevels.values ()) { /* First, clean up expired cache entries. */ iter = toplevel.idCache.values ().iterator (); while (iter.hasNext ()) { /* Get the cache entry. */ tem = iter.next (); /* If it's not valid anymore, remove it. Iterating over a collection whose contents are being removed is undefined unless the removal is performed using the iterator's own `remove' function, so tem.remove cannot be used here. */ if (tem.isValid ()) { /* Otherwise, clean up expired items in its document ID cache. */ pruneCache1 (tem.children.values ()); continue; } iter.remove (); } statIter = toplevel.statCache.values ().iterator (); while (statIter.hasNext ()) { /* Get the cache entry. */ stat = statIter.next (); /* If it's not valid anymore, remove it. Iterating over a collection whose contents are being removed is undefined unless the removal is performed using the iterator's own `remove' function, so tem.remove cannot be used here. */ if (stat.isValid ()) continue; statIter.remove (); } } postPruneMessage (); } /* Cache file information within TOPLEVEL, under the list of children CHILDREN. NAME, ID, and TYPE should respectively be the display name of the document within its parent document (the CacheEntry whose `children' field is CHILDREN), its document ID, and its MIME type. If ID_ENTRY_EXISTS, don't create a new document ID entry within CHILDREN indexed by NAME. Value is the cache entry saved for the document ID. */ private CacheEntry cacheChild (CacheToplevel toplevel, HashMap children, String name, String id, String type, boolean id_entry_exists) { DocIdEntry idEntry; CacheEntry cacheEntry; if (!id_entry_exists) { idEntry = new DocIdEntry (); idEntry.documentId = id; children.put (name, idEntry); } cacheEntry = new CacheEntry (); cacheEntry.type = type; toplevel.idCache.put (id, cacheEntry); return cacheEntry; } /* Cache file status for DOCUMENTID within TOPLEVEL. Value is the new cache entry. CURSOR is the cursor from where to retrieve the file status, in the form of the columns COLUMN_FLAGS, COLUMN_SIZE, COLUMN_MIME_TYPE and COLUMN_LAST_MODIFIED. If NO_CACHE, don't cache the file status; just return the entry. */ private StatCacheEntry cacheFileStatus (String documentId, CacheToplevel toplevel, Cursor cursor, boolean no_cache) { StatCacheEntry entry; int flagsIndex, columnIndex, typeIndex; int sizeIndex, mtimeIndex; String type; /* Obtain the indices for columns wanted from this cursor. */ flagsIndex = cursor.getColumnIndex (Document.COLUMN_FLAGS); sizeIndex = cursor.getColumnIndex (Document.COLUMN_SIZE); typeIndex = cursor.getColumnIndex (Document.COLUMN_MIME_TYPE); mtimeIndex = cursor.getColumnIndex (Document.COLUMN_LAST_MODIFIED); /* COLUMN_LAST_MODIFIED is allowed to be absent in a conforming documents provider. */ if (flagsIndex < 0 || sizeIndex < 0 || typeIndex < 0) return null; /* Get the file status from CURSOR. */ entry = new StatCacheEntry (); entry.flags = cursor.getInt (flagsIndex); type = cursor.getString (typeIndex); if (type == null) return null; entry.isDirectory = type.equals (Document.MIME_TYPE_DIR); if (cursor.isNull (sizeIndex)) /* The size is unknown. */ entry.size = -1; else entry.size = cursor.getLong (sizeIndex); /* mtimeIndex is potentially unset, since document providers aren't obligated to provide modification times. */ if (mtimeIndex >= 0 && !cursor.isNull (mtimeIndex)) entry.mtime = cursor.getLong (mtimeIndex); /* Finally, add this entry to the cache and return. */ if (!no_cache) toplevel.statCache.put (documentId, entry); return entry; } /* Cache the type and as many of the children of the directory designated by DOCUMENTID as possible into TOPLEVEL. CURSOR should be a cursor representing an open directory stream, with its projection consisting of at least the display name, document ID and MIME type columns. Rewind the position of CURSOR to before its first element after completion. */ private void cacheDirectoryFromCursor (CacheToplevel toplevel, String documentId, Cursor cursor) { CacheEntry entry, constituent; int nameColumn, idColumn, typeColumn; String id, name, type; DocIdEntry idEntry; /* Find the numbers of the columns wanted. */ nameColumn = cursor.getColumnIndex (Document.COLUMN_DISPLAY_NAME); idColumn = cursor.getColumnIndex (Document.COLUMN_DOCUMENT_ID); typeColumn = cursor.getColumnIndex (Document.COLUMN_MIME_TYPE); if (nameColumn < 0 || idColumn < 0 || typeColumn < 0) return; entry = new CacheEntry (); /* We know this is a directory already. */ entry.type = Document.MIME_TYPE_DIR; toplevel.idCache.put (documentId, entry); /* Now, try to cache each of its constituents. */ while (cursor.moveToNext ()) { try { name = cursor.getString (nameColumn); id = cursor.getString (idColumn); type = cursor.getString (typeColumn); if (name == null || id == null || type == null) continue; /* First, add the name and ID to ENTRY's map of children. */ idEntry = new DocIdEntry (); idEntry.documentId = id; entry.children.put (id, idEntry); /* Cache the file status for ID within TOPELVEL too; if a directory listing is being requested, it's very likely that a series of calls for file status will follow. */ cacheFileStatus (id, toplevel, cursor, false); /* If this constituent is a directory, don't cache any information about it. It cannot be cached without knowing its children. */ if (type.equals (Document.MIME_TYPE_DIR)) continue; /* Otherwise, create a new cache entry comprised of its type. */ constituent = new CacheEntry (); constituent.type = type; toplevel.idCache.put (documentId, entry); } catch (Exception e) { e.printStackTrace (); continue; } } /* Rewind cursor back to the beginning. */ cursor.moveToPosition (-1); } /* Post a message to run `pruneCache' every CACHE_PRUNE_TIME seconds. */ private void postPruneMessage () { handler.postDelayed (new Runnable () { @Override public void run () { pruneCache (); } }, CACHE_PRUNE_TIME * 1000); } /* Invalidate the cache entry denoted by DOCUMENT_ID, within the document tree URI. Call this after deleting a document or directory. At the same time, remove the final component within the file name CACHENAME from the cache if it exists. */ public void postInvalidateCache (final Uri uri, final String documentId, final String cacheName) { handler.post (new Runnable () { @Override public void run () { CacheToplevel toplevel; HashMap children; String[] components; CacheEntry entry; DocIdEntry idEntry; toplevel = getCache (uri); toplevel.idCache.remove (documentId); toplevel.statCache.remove (documentId); /* If the parent of CACHENAME is cached, remove it. */ children = toplevel.children; components = cacheName.split ("/"); for (String component : components) { /* Java `split' removes trailing empty matches but not leading or intermediary ones. */ if (component.isEmpty ()) continue; if (component == components[components.length - 1]) { /* This is the last component, so remove it from children. */ children.remove (component); return; } else { /* Search for this component within the last level of the cache. */ idEntry = children.get (component); if (idEntry == null) /* Not cached, so return. */ return; entry = toplevel.idCache.get (idEntry.documentId); if (entry == null) /* Not cached, so return. */ return; /* Locate the next component within this directory. */ children = entry.children; } } } }); } /* Invalidate the cache entry denoted by DOCUMENT_ID, within the document tree URI. Call this after deleting a document or directory. At the same time, remove the child referring to DOCUMENTID from within CACHENAME's cache entry if it exists. */ public void postInvalidateCacheDir (final Uri uri, final String documentId, final String cacheName) { handler.post (new Runnable () { @Override public void run () { CacheToplevel toplevel; HashMap children; String[] components; CacheEntry entry; DocIdEntry idEntry; Iterator iter; toplevel = getCache (uri); toplevel.idCache.remove (documentId); toplevel.statCache.remove (documentId); /* Now remove DOCUMENTID from CACHENAME's cache entry, if any. */ children = toplevel.children; components = cacheName.split ("/"); for (String component : components) { /* Java `split' removes trailing empty matches but not leading or intermediary ones. */ if (component.isEmpty ()) continue; /* Search for this component within the last level of the cache. */ idEntry = children.get (component); if (idEntry == null) /* Not cached, so return. */ return; entry = toplevel.idCache.get (idEntry.documentId); if (entry == null) /* Not cached, so return. */ return; /* Locate the next component within this directory. */ children = entry.children; } iter = children.values ().iterator (); while (iter.hasNext ()) { idEntry = iter.next (); if (idEntry.documentId.equals (documentId)) { iter.remove (); break; } } } }); } /* Invalidate the file status cache entry for DOCUMENTID within URI. Call this when the contents of a file (i.e. the constituents of a directory file) may have changed, but the document's display name has not. */ public void postInvalidateStat (final Uri uri, final String documentId) { handler.post (new Runnable () { @Override public void run () { CacheToplevel toplevel; toplevel = getCache (uri); toplevel.statCache.remove (documentId); } }); } /* ``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 idiosyncrasy 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 idiosyncrasy 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 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 result; final CancellationSignal signal; Throwable throwable; result = new EmacsHolder (); 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.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 result; final CancellationSignal signal; Throwable throwable; result = new EmacsHolder (); 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.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, newId, newType; String[] components, projection; Cursor cursor; int nameColumn, idColumn, typeColumn; CacheToplevel toplevel; DocIdEntry idEntry; HashMap children, next; CacheEntry cache; 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; /* Obtain the top level of this cache. */ toplevel = getCache (uri); /* Set the current map of children to this top level. */ children = toplevel.children; /* 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; /* Search for component within the currently cached list of children. */ idEntry = children.get (component); if (idEntry != null) { /* The document ID is known. Now find the corresponding document ID cache. */ cache = toplevel.idCache.get (idEntry.documentId); /* Fetch just the information for this document. */ if (cache == null) cache = idEntry.getCacheEntry (resolver, uri, toplevel, signal); if (cache == null) { /* File status matching idEntry could not be obtained. Treat this as if the file does not exist. */ children.remove (component); if (id == null) id = DocumentsContract.getTreeDocumentId (uri); id_return[0] = id; if ((type == null || type.equals (Document.MIME_TYPE_DIR)) /* ... and type and id currently represent the penultimate component. */ && component == components[components.length - 1]) return -2; return -1; } /* Otherwise, use the cached information. */ id = idEntry.documentId; type = cache.type; children = cache.children; 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 + " = ?"), new String[] { component, }, null, signal); if (cursor == null) return -1; /* Find the column numbers for each of the columns that are wanted. */ nameColumn = cursor.getColumnIndex (Document.COLUMN_DISPLAY_NAME); idColumn = cursor.getColumnIndex (Document.COLUMN_DOCUMENT_ID); typeColumn = cursor.getColumnIndex (Document.COLUMN_MIME_TYPE); if (nameColumn < 0 || idColumn < 0 || typeColumn < 0) return -1; next = null; 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 a component has been found, break out of the loop. */ if (next != null) break; /* 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. */ name = cursor.getString (nameColumn); newId = cursor.getString (idColumn); newType = cursor.getString (typeColumn); /* Any of the three variables above may be NULL if the column data is of the wrong type depending on how the Cursor returned is implemented. */ if (name == null || newId == null || newType == null) return -1; /* Cache this name, even if it isn't the document that's being searched for. */ cache = cacheChild (toplevel, children, name, newId, newType, idEntry != null); /* Record the desired component once it is located, but continue reading and caching items from the cursor. */ if (name.equals (component)) { id = newId; next = cache.children; type = newType; } } children = next; /* 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 cancellation signal. */ private long[] statDocument1 (String uri, String documentId, CancellationSignal signal, boolean noCache) { Uri uriObject, tree; String[] projection; long[] stat; Cursor cursor; CacheToplevel toplevel; StatCacheEntry cache; tree = Uri.parse (uri); if (documentId == null) documentId = DocumentsContract.getTreeDocumentId (tree); /* Create a document URI representing DOCUMENTID within URI's authority. */ uriObject = DocumentsContract.buildDocumentUriUsingTree (tree, documentId); /* See if the file status cache currently contains this document. */ toplevel = getCache (tree); cache = toplevel.statCache.get (documentId); if (cache == null || !cache.isValid ()) { /* Stat this document and enter its information into the cache. */ 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; try { if (!cursor.moveToFirst ()) return null; cache = cacheFileStatus (documentId, toplevel, cursor, noCache); } finally { cursor.close (); } /* If cache is still null, return null. */ if (cache == null) return null; } /* Create the array of file status and populate it with the information within cache. */ stat = new long[3]; stat[0] |= S_IRUSR; if ((cache.flags & Document.FLAG_SUPPORTS_WRITE) != 0) stat[0] |= S_IWUSR; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && (cache.flags & Document.FLAG_VIRTUAL_DOCUMENT) != 0) stat[0] |= S_IFCHR; stat[1] = cache.size; /* Check if this is a directory file. */ if (cache.isDirectory /* 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 | S_IXUSR; /* Directory files cannot be modified if FLAG_DIR_SUPPORTS_CREATE is not set. */ if ((cache.flags & Document.FLAG_DIR_SUPPORTS_CREATE) == 0) stat[0] &= ~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; stat[2] = cache.mtime; 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. If NOCACHE, refrain from placing the file status within the status cache. OperationCanceledException and other typical exceptions may be signaled upon receiving async input or other errors. */ public long[] statDocument (final String uri, final String documentId, final boolean noCache) { return (long[]) runObjectFunction (new SafObjectFunction () { @Override public Object runObject (CancellationSignal signal) { return statDocument1 (uri, documentId, signal, noCache); } }); } /* 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; CacheToplevel toplevel; CacheEntry entry; uriObject = Uri.parse (uri); if (documentId == null) documentId = DocumentsContract.getTreeDocumentId (uriObject); /* If WRITABLE is false and the document ID is cached, use its cached value instead. This speeds up `directory-files-with-attributes' a little. */ if (!writable) { toplevel = getCache (uriObject); entry = toplevel.idCache.get (documentId); if (entry != null) return 0; } /* 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. */ 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, tree; Cursor cursor; String projection[]; CacheToplevel toplevel; tree = 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_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_SIZE, }; cursor = resolver.query (uriObject, projection, null, null, null, signal); /* Create a new cache entry tied to this document ID. */ if (cursor != null) { toplevel = getCache (tree); cacheDirectoryFromCursor (toplevel, documentId, cursor); } /* 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 read, boolean write, boolean truncate, CancellationSignal signal) throws Throwable { Uri treeUri, documentUri; String mode; ParcelFileDescriptor fileDescriptor; CacheToplevel toplevel; 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); /* Select the mode used to open the file. */ if (write) { if (read) { if (truncate) mode = "rwt"; else mode = "rw"; } else /* Set mode to w when WRITE && !READ, disregarding TRUNCATE. In contradiction with the ContentResolver documentation, document providers seem to truncate files whenever w is specified, at least superficially. (But see below.) */ mode = "w"; } else mode = "r"; fileDescriptor = resolver.openFileDescriptor (documentUri, mode, signal); /* If a writable on-disk file descriptor is requested and TRUNCATE is set, then probe the file descriptor to detect if it is actually readable. If not, close this file descriptor and reopen it with MODE set to rw; some document providers granting access to Samba shares don't implement rwt, but these document providers invariably truncate the file opened even when the mode is merely w. This may be ascribed to a mix-up in Android's documentation regarding DocumentsProvider: the `openDocument' function is only documented to accept r or rw, whereas the default implementation of the `openFile' function (which documents rwt) delegates to `openDocument'. */ if (read && write && truncate && fileDescriptor != null && !EmacsNative.ftruncate (fileDescriptor.getFd ())) { try { fileDescriptor.closeWithError ("File descriptor requested" + " is not writable"); } catch (IOException e) { Log.w (TAG, "Leaking unclosed file descriptor " + e); } fileDescriptor = resolver.openFileDescriptor (documentUri, "rw", signal); /* Try to truncate fileDescriptor just to stay on the safe side. */ if (fileDescriptor != null) EmacsNative.ftruncate (fileDescriptor.getFd ()); } else if (!read && write && truncate && fileDescriptor != null) /* Moreover, document providers that return actual seekable files characteristically neglect to truncate the file returned when the access mode is merely w, so attempt to truncate it by hand. */ EmacsNative.ftruncate (fileDescriptor.getFd ()); /* Every time a document is opened, remove it from the file status cache. */ toplevel = getCache (treeUri); toplevel.statCache.remove (documentId); 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. If READ && WRITE, open the file under either the `rw' or `rwt' access mode, which implies that the value must be a seekable on-disk file. If WRITE && !READ or TRUNC && WRITE, also truncate the file after it is opened. If only READ or WRITE is set, value may be a non-seekable FIFO or one end of a socket pair. 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 read, final boolean write, final boolean truncate) { Object tem; tem = runObjectFunction (new SafObjectFunction () { @Override public Object runObject (CancellationSignal signal) throws Throwable { return openDocument1 (uri, documentId, read, write, truncate, signal); } }); return (ParcelFileDescriptor) tem; } };