Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,33 @@ the `.pro` suffix) differ on purpose - do not "fix" the mismatch:
`AndroidFileCache`, the SharedPreferences name in `MainActivity`) follows
`applicationId` and must stay that way, or existing users lose their saved prefs.

### Supported file types are declared twice

`SupportedDocumentTypes` (mime prefixes plus an extension fallback) and the `STRICT_CATCH`
`activity-alias` in `AndroidManifest.xml` describe the same set from two directions - the manifest
is what the system offers the app for, `SupportedDocumentTypes` is what the folder browser on the
landing screen offers itself for. **Nothing keeps them in step, so change both.**

The duplication is deliberate: `MetadataLoader` decides what is really supported by running
libmagic over the file *after* copying it into the cache, and `CoreLoader.isSupported()` /
`RawLoader.isSupported()` both take a filled-in `FileLoader.Options`. A folder listing only has a
name and whatever mime type the provider volunteered, so it cannot ask them and has to guess.

### Storage access

The app declares **no storage permission at all** - only `INTERNET` - and it has to stay that way.
`READ_EXTERNAL_STORAGE` has not reached documents since scoped storage, `READ_MEDIA_*` only covers
images/video/audio, and Play restricts `MANAGE_EXTERNAL_STORAGE` to file managers and backup apps,
so a document viewer asking for it gets the listing rejected.

Everything therefore goes through SAF: `ACTION_OPEN_DOCUMENT` for a single file and
`ACTION_OPEN_DOCUMENT_TREE` (read only, never `FLAG_GRANT_WRITE_URI_PERMISSION`) for the folders
the landing screen browses. Those grants have to be persisted to survive a restart -
`PersistedUriPermissions` takes them and reclaims them by reconciling against the recent list and
the granted trees, rather than releasing on close. Do not add a release next to a
`documentFragment.loadUri()`: that call only queues the load onto `LoaderService`, so the stream is
opened long after it returns.

### Language

Kotlin; support is built into AGP 9, no kotlin plugin is applied. The only java left is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import app.opendocument.droid.ui.activity.MainActivity
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Assert
import org.junit.Before
Expand Down Expand Up @@ -120,6 +121,32 @@ class LandingTests {
)
}

@Test
fun addingAFolderAsksTheSystemForATree() {
Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT_TREE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))

launch()

onView(withId(R.id.landing_empty_add_folder)).perform(click())

Intents.intended(hasAction(Intent.ACTION_OPEN_DOCUMENT_TREE))
}

@Test
fun theFoldersSectionOffersToAddOne() {
seedRecentDocument()

launch()

onView(withText(R.string.landing_section_folders)).check(matches(isDisplayed()))

// the empty state carries a button with the same label, so match the one on screen -
// the empty state is gone once there is a recent document to show
onView(allOf(withText(R.string.landing_add_folder), isDisplayed()))
.check(matches(isDisplayed()))
}

@Test
fun theCatchAllSettingIsOffered() {
seedRecentDocument()
Expand Down Expand Up @@ -155,8 +182,13 @@ class LandingTests {
RecentDocumentsUtil.addRecentDocument(context(), TEST_DOCUMENT, uriOf(requireTestFile()))
}

/**
* Both stores, not just the recent documents: a folder granted on this device - by a previous
* run, or by hand - would keep the empty state from ever being shown.
*/
private fun clearLandingState() {
context().deleteFile("recent_documents.json")
context().deleteFile("folder_trees.json")
}

private fun waitForDocumentFragment(activity: MainActivity): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package app.opendocument.droid.background

import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract

/**
* Lists what is inside a directory tree the user granted access to.
*
* Uses [DocumentsContract] rather than androidx's DocumentFile: DocumentFile.listFiles() issues a
* fresh query per child as soon as the name or type is asked for, which is one binder round trip
* per file. One cursor over the whole folder is what this needs instead.
*/
object DocumentTreeBrowser {

class Child(
val documentId: String,
val displayName: String,
val mimeType: String,
val lastModified: Long,
) {
val isDirectory: Boolean
get() = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
}

/**
* The supported documents and the sub directories of [parentDocumentId], directories first and
* then by name.
*
* Does binder work, so it must not run on the main thread.
*/
fun listChildren(context: Context, treeUri: Uri, parentDocumentId: String): List<Child> {
val childrenUri =
DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId)

val children = ArrayList<Child>()

try {
context.contentResolver
.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
),
null,
null,
null,
)
.use { cursor ->
if (cursor == null) {
return emptyList()
}

while (cursor.moveToNext()) {
val documentId = cursor.getString(0) ?: continue
val displayName = cursor.getString(1) ?: continue
val mimeType = cursor.getString(2) ?: ""

// dotfiles are not something anyone browses for a document
if (displayName.startsWith(".")) {
continue
}

val isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
if (
!isDirectory &&
!SupportedDocumentTypes.isSupported(mimeType, displayName)
) {
continue
}

children.add(Child(documentId, displayName, mimeType, cursor.getLong(3)))
}
}
} catch (e: Exception) {
// a revoked grant, an unmounted sd card or a provider that died - nothing to list
return emptyList()
}

children.sortWith(
compareBy<Child> { !it.isDirectory }
.thenBy(String.CASE_INSENSITIVE_ORDER) {
it.displayName
}
)

return children
}

/** The document id of the folder the user picked, i.e. the root of the tree. */
fun rootDocumentId(treeUri: Uri): String = DocumentsContract.getTreeDocumentId(treeUri)

fun documentUri(treeUri: Uri, documentId: String): Uri =
DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)

/** The name the provider gives the folder itself, falling back to its last path segment. */
fun displayNameOf(context: Context, treeUri: Uri): String {
val documentUri = documentUri(treeUri, rootDocumentId(treeUri))

try {
context.contentResolver
.query(
documentUri,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null,
null,
null,
)
.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
cursor.getString(0)?.let {
return it
}
}
}
} catch (e: Exception) {
// fall through to the uri
}

return treeUri.lastPathSegment ?: treeUri.toString()
}
}
125 changes: 125 additions & 0 deletions app/src/main/java/app/opendocument/droid/background/FolderTreesUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package app.opendocument.droid.background

import android.content.Context
import android.net.Uri
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import org.json.JSONArray
import org.json.JSONObject

/**
* The folders the user granted access to, in the order they added them.
*
* A json file rather than shared preferences because the order matters and every entry carries a
* cached display name, which a StringSet could not express. Same shape as [RecentDocumentsUtil].
*/
object FolderTreesUtil {

/**
* How many folders to keep. Each holds a persisted uri permission, and those are capped per
* package - together with [RecentDocumentList.MAX_ENTRIES] this stays far below the limit.
*/
const val MAX_TREES: Int = 8

private const val FILENAME = "folder_trees.json"

private const val KEY_URI = "uri"
private const val KEY_DISPLAY_NAME = "displayName"
private const val KEY_ADDED_AT = "addedAt"

class FolderTree(val uri: Uri, val displayName: String, val addedAt: Long)

@Synchronized fun getFolderTrees(context: Context): List<FolderTree> = read(context)

/**
* Adds [uri], or refreshes the name of a folder that is already there.
*
* @return the folders that fell out, so their grants can be released.
*/
@Synchronized
fun addFolderTree(context: Context, uri: Uri, displayName: String): List<FolderTree> {
val value = uri.toString()

val remaining = read(context).filter { it.uri.toString() != value }
val trees = ArrayList<FolderTree>(remaining.size + 1)
trees.addAll(remaining)
trees.add(FolderTree(uri, displayName, System.currentTimeMillis()))

if (trees.size <= MAX_TREES) {
write(context, trees)

return emptyList()
}

// oldest first, so the ones that drop off the front are the least recently added
val evicted = trees.subList(0, trees.size - MAX_TREES).toList()
write(context, trees.subList(trees.size - MAX_TREES, trees.size).toList())

return evicted
}

@Synchronized
fun removeFolderTree(context: Context, uri: Uri) {
val value = uri.toString()

val current = read(context)
val remaining = current.filter { it.uri.toString() != value }

if (remaining.size != current.size) {
write(context, remaining)
}
}

private fun read(context: Context): List<FolderTree> {
val jsonArray =
try {
context.openFileInput(FILENAME).use { input ->
BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).use { reader ->
JSONArray(reader.readText())
}
}
} catch (e: Exception) {
return emptyList()
}

val trees = ArrayList<FolderTree>(jsonArray.length())
for (i in 0 until jsonArray.length()) {
val tree = jsonArray.optJSONObject(i) ?: continue

val uri = tree.optString(KEY_URI, "")
if (uri.isEmpty()) {
continue
}

trees.add(
FolderTree(
Uri.parse(uri),
tree.optString(KEY_DISPLAY_NAME, uri),
tree.optLong(KEY_ADDED_AT, 0),
)
)
}

return trees
}

private fun write(context: Context, trees: List<FolderTree>) {
val jsonArray = JSONArray()
for (tree in trees) {
val json = JSONObject()
json.put(KEY_URI, tree.uri.toString())
json.put(KEY_DISPLAY_NAME, tree.displayName)
json.put(KEY_ADDED_AT, tree.addedAt)

jsonArray.put(json)
}

context.openFileOutput(FILENAME, Context.MODE_PRIVATE).use { output ->
OutputStreamWriter(output, StandardCharsets.UTF_8).use { writer ->
writer.write(jsonArray.toString())
}
}
}
}
Loading
Loading