/* 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