diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 362a87d12..dbd5b4ac6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -123,5 +123,17 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/log_share_paths" />
+
+
+
+
+
+
diff --git a/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt b/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt
new file mode 100644
index 000000000..c5bbef88e
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt
@@ -0,0 +1,243 @@
+package com.droiddeck.launcher.files
+
+import android.content.Context
+import android.content.pm.ProviderInfo
+import android.content.res.AssetFileDescriptor
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.graphics.Point
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract.Document
+import android.provider.DocumentsContract.Root
+import android.provider.DocumentsProvider
+import android.webkit.MimeTypeMap
+import com.droiddeck.launcher.R
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.nio.file.FileVisitResult
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.Path
+import java.nio.file.SimpleFileVisitor
+import java.nio.file.attribute.BasicFileAttributes
+
+/**
+ * The app's own data folder (the Linux runtime, settings, logs) in Android's file picker, under the
+ * app's name beside the phone's storage - what MT Manager's "documents provider" injection does, as
+ * part of the app. Only the system's documents UI can bind it (MANAGE_DOCUMENTS); another app sees
+ * a file only when the user picks it there.
+ *
+ * A document id is the file's absolute path, and nothing outside the data folder is ever handed out
+ * or accepted: every id is resolved and checked to lie inside it.
+ */
+class AppFilesProvider : DocumentsProvider() {
+ private lateinit var base: File
+ private lateinit var baseCanonical: String
+
+ override fun attachInfo(context: Context, info: ProviderInfo) {
+ super.attachInfo(context, info)
+ base = context.dataDir
+ baseCanonical = base.canonicalPath
+ }
+
+ override fun onCreate() = true
+
+ override fun queryRoots(projection: Array?): Cursor {
+ val ctx = context!!
+ val result = MatrixCursor(projection ?: ROOT_COLUMNS)
+ result.newRow().apply {
+ add(Root.COLUMN_ROOT_ID, ROOT_ID)
+ add(Root.COLUMN_DOCUMENT_ID, base.absolutePath)
+ add(Root.COLUMN_TITLE, titleFor(ctx))
+ add(Root.COLUMN_SUMMARY, null)
+ add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_IS_CHILD)
+ add(Root.COLUMN_MIME_TYPES, "*/*")
+ add(Root.COLUMN_AVAILABLE_BYTES, base.freeSpace)
+ add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
+ }
+ return result
+ }
+
+ override fun queryDocument(documentId: String, projection: Array?): Cursor =
+ MatrixCursor(projection ?: DOCUMENT_COLUMNS).also { addRow(it, fileFor(documentId)) }
+
+ override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor {
+ val result = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
+ // A link in the runtime that leads out of the data folder (/etc/localtime and the like) is
+ // left out rather than listed as something that cannot be opened.
+ fileFor(parentDocumentId).listFiles()?.filter { inside(it) }?.forEach { addRow(result, it) }
+ return result
+ }
+
+ override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor =
+ ParcelFileDescriptor.open(fileFor(documentId), ParcelFileDescriptor.parseMode(mode))
+
+ override fun openDocumentThumbnail(documentId: String, sizeHint: Point?, signal: CancellationSignal?): AssetFileDescriptor {
+ val file = fileFor(documentId)
+ return AssetFileDescriptor(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY), 0, file.length())
+ }
+
+ override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
+ val parent = fileFor(parentDocumentId)
+ val name = displayName.substringAfterLast('/')
+ var file = File(parent, name)
+ var n = 2
+ while (file.exists()) file = File(parent, "$name (${n++})")
+ val made = try {
+ if (mimeType == Document.MIME_TYPE_DIR) file.mkdir() else file.createNewFile()
+ } catch (e: IOException) {
+ false
+ }
+ if (!made) throw FileNotFoundException("could not create ${file.path}")
+ return file.absolutePath
+ }
+
+ override fun deleteDocument(documentId: String) {
+ val file = fileFor(documentId)
+ if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
+ try {
+ deleteTree(file.toPath())
+ } catch (e: IOException) {
+ throw FileNotFoundException("could not delete $documentId: ${e.message}")
+ }
+ }
+
+ /**
+ * Deletes a file or a folder and what is in it without ever following a link: the runtime holds
+ * links to places outside it, and deleting a folder must remove the link, not what it points at.
+ */
+ private fun deleteTree(root: Path) {
+ if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
+ Files.delete(root)
+ return
+ }
+ Files.walkFileTree(root, object : SimpleFileVisitor() {
+ override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+ Files.delete(file)
+ return FileVisitResult.CONTINUE
+ }
+
+ override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
+ if (exc != null) throw exc
+ Files.delete(dir)
+ return FileVisitResult.CONTINUE
+ }
+ })
+ }
+
+ override fun removeDocument(documentId: String, parentDocumentId: String) = deleteDocument(documentId)
+
+ override fun renameDocument(documentId: String, displayName: String): String {
+ val file = fileFor(documentId)
+ val target = File(file.parentFile, displayName.substringAfterLast('/'))
+ if (target.exists() || !file.renameTo(target)) throw FileNotFoundException("could not rename $documentId")
+ return target.absolutePath
+ }
+
+ override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String {
+ val source = fileFor(sourceDocumentId)
+ val targetParent = fileFor(targetParentDocumentId)
+ if (!targetParent.isDirectory) throw FileNotFoundException("$targetParentDocumentId is not a folder")
+ val target = File(targetParent, source.name)
+ if (target.exists() || !source.renameTo(target)) throw FileNotFoundException("could not move $sourceDocumentId")
+ return target.absolutePath
+ }
+
+ override fun getDocumentType(documentId: String): String = mimeOf(fileFor(documentId))
+
+ override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
+ val parent = runCatching { fileFor(parentDocumentId).absolutePath }.getOrNull() ?: return false
+ val child = runCatching { fileFor(documentId).absolutePath }.getOrNull() ?: return false
+ return child == parent || child.startsWith("$parent/")
+ }
+
+ override fun querySearchDocuments(rootId: String, query: String, projection: Array?): Cursor {
+ val result = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
+ val needle = query.lowercase()
+ val pending = ArrayDeque(listOf(base))
+ var visited = 0
+ // The runtime is tens of thousands of files: a bounded walk, not the whole tree.
+ while (pending.isNotEmpty() && result.count < 50 && visited++ < 20000) {
+ val f = pending.removeFirst()
+ if (!inside(f)) continue
+ if (f.isDirectory) f.listFiles()?.let { pending.addAll(it) }
+ else if (f.name.lowercase().contains(needle)) addRow(result, f)
+ }
+ return result
+ }
+
+ /** The file an id names, when it exists and lies inside the data folder (links followed). */
+ private fun fileFor(documentId: String): File {
+ val f = File(documentId)
+ if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
+ if (!f.exists()) throw FileNotFoundException("$documentId not found")
+ return f
+ }
+
+ private fun inside(f: File): Boolean {
+ // The folder's own entries are listed as they are; a link is judged by where it leads.
+ val abs = f.absoluteFile.normalize().path
+ if (abs != base.absolutePath && !abs.startsWith(base.absolutePath + "/")) return false
+ val real = runCatching { f.canonicalPath }.getOrNull() ?: return false
+ return real == baseCanonical || real.startsWith("$baseCanonical/")
+ }
+
+ private fun addRow(result: MatrixCursor, file: File) {
+ var flags = 0
+ if (file.isDirectory) {
+ if (file.canWrite()) flags = flags or Document.FLAG_DIR_SUPPORTS_CREATE
+ } else if (file.canWrite()) {
+ flags = flags or Document.FLAG_SUPPORTS_WRITE
+ }
+ if (file.absolutePath != base.absolutePath && file.parentFile?.canWrite() == true) {
+ flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME or
+ Document.FLAG_SUPPORTS_MOVE or Document.FLAG_SUPPORTS_REMOVE
+ }
+ val mime = mimeOf(file)
+ if (mime.startsWith("image/")) flags = flags or Document.FLAG_SUPPORTS_THUMBNAIL
+ result.newRow().apply {
+ add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
+ add(Document.COLUMN_DISPLAY_NAME, if (file.absolutePath == base.absolutePath) titleFor(context!!) else file.name)
+ add(Document.COLUMN_SIZE, if (file.isDirectory) null else file.length())
+ add(Document.COLUMN_MIME_TYPE, mime)
+ add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
+ add(Document.COLUMN_FLAGS, flags)
+ }
+ }
+
+ private fun mimeOf(file: File): String {
+ if (file.isDirectory) return Document.MIME_TYPE_DIR
+ val ext = file.extension.lowercase()
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "application/octet-stream"
+ }
+
+ companion object {
+ private const val ROOT_ID = "app"
+
+ private val ROOT_COLUMNS = arrayOf(
+ Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES, Root.COLUMN_FLAGS, Root.COLUMN_ICON,
+ Root.COLUMN_TITLE, Root.COLUMN_SUMMARY, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES,
+ )
+ private val DOCUMENT_COLUMNS = arrayOf(
+ Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
+ )
+
+ /**
+ * The name in the picker. The same app ships under other package names (for the performance
+ * modes some phones give only named apps); each says which it is, so four installs are not
+ * four identical "DroidDeck" entries.
+ */
+ fun titleFor(context: Context): String {
+ val name = context.getString(R.string.app_name)
+ return when (context.packageName) {
+ "com.tencent.ig" -> "$name PUBG"
+ "com.antutu.benchmark.full" -> "$name AnTuTu"
+ "com.ludashi.benchmark" -> "$name Ludashi"
+ else -> name
+ }
+ }
+ }
+}