This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
android {
|
||||
namespace = "dev.fileworkbench.android"
|
||||
compileSdk = 36
|
||||
defaultConfig {
|
||||
applicationId = "dev.fileworkbench.android"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 5
|
||||
versionName = "0.2.3"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
}
|
||||
kotlin { jvmToolchain(17) }
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":platform-jvm"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.coroutines.android)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# No app-specific shrinking rules in the read-only MVP.
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package dev.fileworkbench.android
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dev.fileworkbench.android.browser.*
|
||||
import dev.fileworkbench.android.testing.FixtureDocumentsProvider
|
||||
import dev.fileworkbench.core.browser.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Included, not executed by the offline source checks. Requires :androidApp:connectedDebugAndroidTest. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BrowserIntegrationTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val root get() = DocumentsContract.buildTreeDocumentUri("${context.packageName}.browserfixture", "root")
|
||||
private fun uri(id: String) = DocumentsContract.buildDocumentUriUsingTree(root, id)
|
||||
|
||||
@Test fun directoryUsesOneBoundedMetadataQuery() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
FixtureDocumentsProvider.childQueries.set(0)
|
||||
val listing = store.list(location, store.rootEntry(location).id)
|
||||
assertEquals(1, FixtureDocumentsProvider.childQueries.get())
|
||||
assertEquals(11, listing.entries.size)
|
||||
assertNull(listing.information)
|
||||
assertFalse(listing.providerLoading)
|
||||
assertFalse(listing.truncated)
|
||||
assertTrue(listing.entries.any { it.name == "Subfolder" && it.isDirectory })
|
||||
assertTrue(listing.entries.any { it.virtual })
|
||||
assertTrue(listing.entries.first { it.name == "sample.txt" }.size == null)
|
||||
}
|
||||
// Regression for 0.2.0/0.2.1: a successful cursor with no EXTRA_ERROR must
|
||||
// not fall through the nullable return value of use into a "no listing" error.
|
||||
@Test fun emptyDirectoryWithoutExtrasIsAValidListing() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("empty").toString())
|
||||
assertTrue(listing.entries.isEmpty())
|
||||
assertNull(listing.information)
|
||||
assertFalse(listing.providerLoading)
|
||||
assertFalse(listing.truncated)
|
||||
}
|
||||
@Test fun informationalExtraWithoutAnErrorDoesNotDiscardRows() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("info").toString())
|
||||
assertEquals(listOf("sample.txt"), listing.entries.map { it.name })
|
||||
assertEquals("Cached files", listing.information)
|
||||
assertFalse(listing.providerLoading)
|
||||
}
|
||||
@Test fun loadingExtraWithoutAnErrorRetainsCachedRows() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("loading").toString())
|
||||
assertEquals(listOf("sample.txt"), listing.entries.map { it.name })
|
||||
assertTrue(listing.providerLoading)
|
||||
assertNull(listing.information)
|
||||
}
|
||||
@Test fun loadingDirectoryCanInitiallyHaveNoRows() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("loading-empty").toString())
|
||||
assertTrue(listing.entries.isEmpty())
|
||||
assertTrue(listing.providerLoading)
|
||||
assertNull(listing.information)
|
||||
}
|
||||
@Test fun providerErrorMessageIsRetainedAlongsideAvailableRows() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("provider-error").toString())
|
||||
assertEquals(listOf("sample.txt"), listing.entries.map { it.name })
|
||||
assertEquals("Some files are unavailable", listing.information)
|
||||
}
|
||||
@Test fun subfoldersUseOpaqueProviderIds() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("sub").toString())
|
||||
assertEquals(listOf("nested.txt"), listing.entries.map { it.name })
|
||||
}
|
||||
@Test fun hugeFolderIsExplicitlyMarkedTruncated() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val listing = store.list(location, uri("many").toString())
|
||||
assertEquals(BrowserLimits.MAX_ENTRIES, listing.entries.size)
|
||||
assertTrue(listing.truncated)
|
||||
}
|
||||
@Test fun singlePickReturnsOnlyATemporaryCopiedReadUri() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val entry = store.metadata(uri("text"))
|
||||
val result = PickedFileStore(context).prepare(PickerRequest("text/*"), listOf(entry))
|
||||
assertEquals(Intent.FLAG_GRANT_READ_URI_PERMISSION, result.flags)
|
||||
assertEquals("${context.packageName}.files", result.data!!.authority)
|
||||
assertFalse(DocumentsContract.isTreeUri(result.data!!))
|
||||
assertEquals(1, result.clipData!!.itemCount)
|
||||
assertEquals("text/plain", result.type)
|
||||
assertEquals("Fixture text for the browser.\n", context.contentResolver.openInputStream(result.data!!)!!.bufferedReader().use { it.readText() })
|
||||
}
|
||||
@Test fun copiedExtensionlessFileRetainsItsProviderMimeType() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val entry = store.metadata(uri("opaque"))
|
||||
val result = PickedFileStore(context).prepare(PickerRequest("application/json"), listOf(entry))
|
||||
assertEquals("application/json", context.contentResolver.getType(result.data!!))
|
||||
}
|
||||
@Test fun multiPickUsesClipDataWithoutPrefixOrWriteGrants() = runBlocking {
|
||||
val store = SafBrowserStore(context)
|
||||
val entries = listOf(store.metadata(uri("text")), store.metadata(uri("json")))
|
||||
val result = PickedFileStore(context).prepare(PickerRequest(allowMultiple = true), entries)
|
||||
assertNull(result.data)
|
||||
assertEquals(2, result.clipData!!.itemCount)
|
||||
assertEquals(Intent.FLAG_GRANT_READ_URI_PERMISSION, result.flags)
|
||||
for (index in 0..1) assertEquals("${context.packageName}.files", result.clipData!!.getItemAt(index).uri.authority)
|
||||
}
|
||||
@Test fun filePickerAndFilesCategoryResolveButSystemDocumentActionDoesNot() {
|
||||
val manager = context.packageManager
|
||||
fun activities(intent: Intent) = manager.queryIntentActivities(intent.setPackage(context.packageName), PackageManager.MATCH_DEFAULT_ONLY)
|
||||
assertTrue(activities(Intent(Intent.ACTION_GET_CONTENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE))
|
||||
.any { it.activityInfo.name.endsWith("FilePickerActivity") })
|
||||
assertTrue(activities(Intent(Intent.ACTION_MAIN).addCategory("android.intent.category.APP_FILES"))
|
||||
.any { it.activityInfo.name.endsWith("MainActivity") })
|
||||
assertTrue(activities(Intent(Intent.ACTION_OPEN_DOCUMENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE)).isEmpty())
|
||||
}
|
||||
@Test fun localOnlyAndMimeConstraintsSurviveSystemPickerDelegation() {
|
||||
val request = PickerIntents.request(Intent(Intent.ACTION_GET_CONTENT).setType("image/*")
|
||||
.putExtra(Intent.EXTRA_LOCAL_ONLY, true).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/png")))
|
||||
val delegated = PickerIntents.openDocument(request)
|
||||
assertEquals(Intent.ACTION_OPEN_DOCUMENT, delegated.action)
|
||||
assertTrue(delegated.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false))
|
||||
assertTrue(delegated.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false))
|
||||
assertArrayEquals(arrayOf("image/png"), delegated.getStringArrayExtra(Intent.EXTRA_MIME_TYPES))
|
||||
}
|
||||
@Test fun incomingExtrasCannotSeedRestoredSelectionsOrPreviewUris() {
|
||||
val incoming = Intent(Intent.ACTION_GET_CONTENT).setType("image/*")
|
||||
.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
|
||||
.putExtra("sourceUri", uri("text").toString())
|
||||
.putExtra("browser.selection", "malicious preselection")
|
||||
.putExtra("browser.root", root.toString())
|
||||
val safe = PickerIntents.sanitizedPickerIntent(incoming, PickerIntents.request(incoming))
|
||||
assertFalse(safe.hasExtra("sourceUri"))
|
||||
assertFalse(safe.hasExtra("browser.selection"))
|
||||
assertFalse(safe.hasExtra("browser.root"))
|
||||
assertTrue(safe.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false))
|
||||
assertFalse(PickerIntents.sanitizedViewerIntent(incoming).hasExtra("sourceUri"))
|
||||
}
|
||||
@Test fun providerVirtualDocumentsAreNotPickerCandidates() = runBlocking {
|
||||
val entry = SafBrowserStore(context).metadata(uri("virtual"))
|
||||
assertTrue(entry.virtual)
|
||||
assertFalse(PickerRequest().accepts(entry))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.fileworkbench.android
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import dev.fileworkbench.android.render.PdfRenderClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/** Included but NOT executed in the source-authoring environment. Requires an emulator/device. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ViewerSmokeTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun homeOffersAnAccountFreeOpenAction() {
|
||||
compose.onNodeWithText("Files, not services.").assertIsDisplayed()
|
||||
compose.onNodeWithText("Open file").assertIsDisplayed()
|
||||
compose.onNodeWithText("Add folder").assertIsDisplayed()
|
||||
compose.onNodeWithText("Browse").assertIsDisplayed()
|
||||
compose.onNodeWithText("Recent").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun isolatedPdfServiceRendersARealDescriptor() = runBlocking {
|
||||
val context = compose.activity.applicationContext
|
||||
val file = File.createTempFile("instrumentation-fixture-", ".pdf", context.cacheDir)
|
||||
try {
|
||||
// PdfDocument has close(), but does not implement Closeable/AutoCloseable.
|
||||
// Kotlin's use() therefore cannot be called on it.
|
||||
val pdf = PdfDocument()
|
||||
try {
|
||||
val page = pdf.startPage(PdfDocument.PageInfo.Builder(300, 400, 1).create())
|
||||
try {
|
||||
page.canvas.drawColor(Color.WHITE)
|
||||
page.canvas.drawText(
|
||||
"Fileworkbench PDF smoke test", 16f, 50f,
|
||||
Paint().apply { color = Color.BLACK; textSize = 16f }
|
||||
)
|
||||
} finally {
|
||||
// A started page must be finished before closing the document.
|
||||
pdf.finishPage(page)
|
||||
}
|
||||
file.outputStream().use { pdf.writeTo(it) }
|
||||
} finally {
|
||||
pdf.close()
|
||||
}
|
||||
|
||||
val client = PdfRenderClient(context)
|
||||
try {
|
||||
val page = client.render(file, 0)
|
||||
assertEquals(1, page.count)
|
||||
assertEquals(0, page.index)
|
||||
assertTrue(page.bitmap.width > 0)
|
||||
assertTrue(page.bitmap.height > 0)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
} finally {
|
||||
// Clean up even when fixture creation or rendering fails.
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Debug-only synthetic files for instrumentation. Never included in the release variant. -->
|
||||
<provider
|
||||
android:name=".testing.FixtureDocumentsProvider"
|
||||
android:authorities="${applicationId}.browserfixture"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.fileworkbench.android.testing
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsProvider
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/** Synthetic sample documents only. This provider exists solely in the debug build for device tests. */
|
||||
class FixtureDocumentsProvider : DocumentsProvider() {
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
val columns = projection ?: arrayOf(Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS, Root.COLUMN_MIME_TYPES)
|
||||
return MatrixCursor(columns).apply {
|
||||
val values = mapOf<String, Any>(Root.COLUMN_ROOT_ID to "fixture", Root.COLUMN_DOCUMENT_ID to "root",
|
||||
Root.COLUMN_TITLE to "Fileworkbench test files", Root.COLUMN_FLAGS to (Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_LOCAL_ONLY),
|
||||
Root.COLUMN_MIME_TYPES to "*/*")
|
||||
addRow(columns.map { values[it] })
|
||||
}
|
||||
}
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor = documents(listOf(documentId), projection)
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
|
||||
childQueries.incrementAndGet()
|
||||
return documents(when (parentDocumentId) {
|
||||
"root" -> listOf("sub", "text", "json", "hidden", "virtual", "many", "empty", "info", "loading", "loading-empty", "provider-error")
|
||||
"sub" -> listOf("nested")
|
||||
"many" -> (0..5001).map { "many-$it" }
|
||||
"empty", "loading-empty" -> emptyList()
|
||||
"info", "loading", "provider-error" -> listOf("text")
|
||||
else -> throw FileNotFoundException("Unknown test folder")
|
||||
}, projection).apply {
|
||||
// Most real providers omit EXTRA_ERROR on success. All other folders retain
|
||||
// the MatrixCursor default extras so that the normal path is tested too.
|
||||
when (parentDocumentId) {
|
||||
"info" -> setExtras(Bundle().apply {
|
||||
putString(DocumentsContract.EXTRA_INFO, "Cached files")
|
||||
})
|
||||
"loading", "loading-empty" -> setExtras(Bundle().apply {
|
||||
putBoolean(DocumentsContract.EXTRA_LOADING, true)
|
||||
})
|
||||
"provider-error" -> setExtras(Bundle().apply {
|
||||
putString(DocumentsContract.EXTRA_INFO, "Cached files")
|
||||
putString(DocumentsContract.EXTRA_ERROR, "Some files are unavailable")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = when (parentDocumentId) {
|
||||
"root" -> documentId in setOf("sub", "text", "json", "hidden", "virtual", "many", "nested", "opaque", "empty", "info", "loading", "loading-empty", "provider-error") || documentId.startsWith("many-")
|
||||
"sub" -> documentId == "nested"
|
||||
"many" -> documentId.startsWith("many-")
|
||||
"info", "loading", "provider-error" -> documentId == "text"
|
||||
else -> false
|
||||
}
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor {
|
||||
if (mode != "r") throw FileNotFoundException("Fixtures are read-only")
|
||||
val content = when (documentId) {
|
||||
"text" -> "Fixture text for the browser.\n"
|
||||
"json", "opaque" -> "{\"fixture\":true}\n"
|
||||
"hidden" -> "hidden=true\n"
|
||||
"nested" -> "Nested document.\n"
|
||||
else -> throw FileNotFoundException("Not an openable fixture file")
|
||||
}
|
||||
val directory = File(requireNotNull(context).cacheDir, "browser-fixtures").apply { mkdirs() }
|
||||
val file = File(directory, "$documentId.txt").apply { writeText(content) }
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
private fun documents(ids: List<String>, projection: Array<out String>?): Cursor {
|
||||
val columns = projection ?: arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS)
|
||||
return MatrixCursor(columns).apply {
|
||||
ids.forEach { id ->
|
||||
val directory = id in setOf("root", "sub", "many", "empty", "info", "loading", "loading-empty", "provider-error")
|
||||
val name = when (id) { "root" -> "Test root"; "sub" -> "Subfolder"; "text" -> "sample.txt"; "json" -> "data.json"
|
||||
"opaque" -> "file-without-extension"; "hidden" -> ".hidden"; "virtual" -> "Virtual document"; "nested" -> "nested.txt"; "many" -> "Large folder"; else -> "$id.txt" }
|
||||
val values = mapOf<String, Any?>(Document.COLUMN_DOCUMENT_ID to id, Document.COLUMN_DISPLAY_NAME to name,
|
||||
Document.COLUMN_MIME_TYPE to if (directory) Document.MIME_TYPE_DIR else if (id in setOf("json", "opaque")) "application/json" else "text/plain",
|
||||
Document.COLUMN_SIZE to null, Document.COLUMN_LAST_MODIFIED to 1_700_000_000_000L,
|
||||
Document.COLUMN_FLAGS to if (id == "virtual") Document.FLAG_VIRTUAL_DOCUMENT else 0)
|
||||
addRow(columns.map { values[it] })
|
||||
}
|
||||
}
|
||||
}
|
||||
companion object { val childQueries = AtomicInteger(0) }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Deliberately no INTERNET, storage, contacts, account or media permissions. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Fileworkbench"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Files-app launch category (Android 10+). This is not the web-browser role. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.APP_FILES" />
|
||||
</intent-filter>
|
||||
<!-- Accessible content-tree folder links; no claim to raw filesystem access. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="vnd.android.document/directory" />
|
||||
<data android:mimeType="inode/directory" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="image/jpeg" />
|
||||
<data android:mimeType="image/png" />
|
||||
<data android:mimeType="image/webp" />
|
||||
<data android:mimeType="image/gif" />
|
||||
<data android:mimeType="image/bmp" />
|
||||
<data android:mimeType="image/svg+xml" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="application/json" />
|
||||
<data android:mimeType="application/xml" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/x-zip-compressed" />
|
||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
|
||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
|
||||
<data android:mimeType="application/vnd.oasis.opendocument.text" />
|
||||
<data android:mimeType="application/vnd.oasis.opendocument.spreadsheet" />
|
||||
<data android:mimeType="application/vnd.oasis.opendocument.presentation" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="application/json" />
|
||||
<data android:mimeType="application/xml" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
|
||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
|
||||
<data android:mimeType="application/vnd.oasis.opendocument.text" />
|
||||
<data android:mimeType="application/vnd.oasis.opendocument.spreadsheet" />
|
||||
<data android:mimeType="application/vnd.oasis.opendocument.presentation" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- GET_CONTENT is an import picker. OPEN_DOCUMENT / OPEN_DOCUMENT_TREE remain system-owned. -->
|
||||
<activity
|
||||
android:name=".FilePickerActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:label="Fileworkbench files">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.OPENABLE" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".render.PdfRenderService"
|
||||
android:exported="false"
|
||||
android:isolatedProcess="true"
|
||||
android:process=":pdf_renderer" />
|
||||
<provider
|
||||
android:name=".browser.WorkbenchFileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.fileworkbench.android
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import dev.fileworkbench.android.browser.BrowserViewModel
|
||||
import dev.fileworkbench.android.browser.PickerIntents
|
||||
import dev.fileworkbench.android.ui.FileworkbenchApp
|
||||
|
||||
/** Separate standard-launch activity: an import request cannot hijack an existing viewer task/result. */
|
||||
class FilePickerActivity : ComponentActivity() {
|
||||
private val viewer: ViewerViewModel by viewModels()
|
||||
private val browser: BrowserViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val request = runCatching { PickerIntents.request(intent) }.getOrNull()
|
||||
if (request != null) setIntent(PickerIntents.sanitizedPickerIntent(intent, request))
|
||||
else setIntent(android.content.Intent(intent).replaceExtras(Bundle()))
|
||||
super.onCreate(savedInstanceState)
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
if (request == null) { finish(); return }
|
||||
enableEdgeToEdge()
|
||||
browser.start(request)
|
||||
setContent {
|
||||
FileworkbenchApp(viewer, browser,
|
||||
onPickerResult = { result -> setResult(Activity.RESULT_OK, result); finish() },
|
||||
onPickerCancel = { setResult(Activity.RESULT_CANCELED); finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.fileworkbench.android
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.IntentCompat
|
||||
import dev.fileworkbench.android.ui.FileworkbenchApp
|
||||
import dev.fileworkbench.android.browser.BrowserViewModel
|
||||
import dev.fileworkbench.android.browser.PickerIntents
|
||||
import android.provider.DocumentsContract
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewer: ViewerViewModel by viewModels()
|
||||
private val browser: BrowserViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Sanitize before any ViewModel can receive activity extras as SavedStateHandle defaults.
|
||||
setIntent(PickerIntents.sanitizedViewerIntent(intent))
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
browser.start()
|
||||
setContent { FileworkbenchApp(viewer, browser) }
|
||||
if (savedInstanceState == null) receive(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
browser.refreshLocations()
|
||||
viewer.refreshRecents()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
val safe = PickerIntents.sanitizedViewerIntent(intent)
|
||||
setIntent(safe)
|
||||
receive(safe)
|
||||
}
|
||||
|
||||
private fun receive(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
if (intent.action == Intent.ACTION_MAIN) {
|
||||
if (intent.hasCategory("android.intent.category.APP_FILES")) { viewer.closeDocument(); browser.locationsHome() }
|
||||
return
|
||||
}
|
||||
if (intent.action == Intent.ACTION_VIEW && intent.type in setOf(DocumentsContract.Document.MIME_TYPE_DIR, "inode/directory")) {
|
||||
viewer.closeDocument()
|
||||
intent.data?.let { browser.receiveDirectory(it, intent.flags) }
|
||||
?: browser.notice("No folder location was supplied. Use Add folder.")
|
||||
return
|
||||
}
|
||||
val uri = when (intent.action) {
|
||||
Intent.ACTION_VIEW -> intent.data
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?: intent.clipData?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.uri
|
||||
else -> null
|
||||
}
|
||||
if (uri == null) viewer.externalOpenError()
|
||||
else viewer.open(uri, persist = intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package dev.fileworkbench.android
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.fileworkbench.android.data.DocumentStore
|
||||
import dev.fileworkbench.android.data.RecentDocument
|
||||
import dev.fileworkbench.android.data.StagedDocument
|
||||
import dev.fileworkbench.android.render.ImageLoader
|
||||
import dev.fileworkbench.android.render.PdfRenderClient
|
||||
import dev.fileworkbench.core.*
|
||||
import dev.fileworkbench.platform.LocalPreviewLoader
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/** State survives configuration changes; reopening after process death still requires a valid provider grant. */
|
||||
data class ViewerState(
|
||||
val document: StagedDocument? = null,
|
||||
val preview: DocumentPreview? = null,
|
||||
val bitmap: Bitmap? = null,
|
||||
val page: Int = 0,
|
||||
val pageCount: Int = 0,
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val notice: String? = null,
|
||||
val recents: List<RecentDocument> = emptyList(),
|
||||
)
|
||||
|
||||
class ViewerViewModel(application: Application, private val savedState: SavedStateHandle) : AndroidViewModel(application) {
|
||||
private val store = DocumentStore(application)
|
||||
private val pdf = PdfRenderClient(application)
|
||||
private val _state = MutableStateFlow(ViewerState())
|
||||
val state = _state.asStateFlow()
|
||||
private var documentJob: Job? = null
|
||||
private var generation = 0
|
||||
private val processing = Mutex()
|
||||
|
||||
init {
|
||||
store.cleanOrphanedSessions()
|
||||
_state.update { it.copy(recents = store.recents()) }
|
||||
savedState.get<String>("sourceUri")?.let { open(Uri.parse(it), restorePage = savedState["pdfPage"] ?: 0) }
|
||||
}
|
||||
|
||||
fun open(uri: Uri, persist: Boolean = false, restorePage: Int = 0) {
|
||||
val token = ++generation
|
||||
documentJob?.cancel()
|
||||
documentJob = viewModelScope.launch {
|
||||
processing.withLock {
|
||||
var staged: StagedDocument? = null
|
||||
var accepted = false
|
||||
_state.update { it.copy(loading = true, error = null, notice = null) }
|
||||
try {
|
||||
if (persist) withContext(Dispatchers.IO) { store.persistReadGrant(uri) }
|
||||
staged = store.stage(uri)
|
||||
ensureActive()
|
||||
if (generation != token) return@launch
|
||||
val old = _state.value.document
|
||||
val current = staged
|
||||
savedState["sourceUri"] = current.source
|
||||
savedState["pdfPage"] = restorePage
|
||||
store.remember(current)
|
||||
_state.value = ViewerState(document = current, loading = true, recents = store.recents())
|
||||
accepted = true
|
||||
old?.file?.let(store::releaseSnapshot)
|
||||
when (current.kind) {
|
||||
FileKind.PDF -> {
|
||||
val page = pdf.render(current.file, restorePage)
|
||||
ensureActive()
|
||||
_state.update { it.copy(bitmap = page.bitmap, page = page.index, pageCount = page.count, loading = false) }
|
||||
}
|
||||
FileKind.IMAGE -> {
|
||||
val bitmap = withContext(Dispatchers.IO) { ImageLoader.decode(current.file) }
|
||||
ensureActive()
|
||||
_state.update { it.copy(bitmap = bitmap, loading = false) }
|
||||
}
|
||||
else -> {
|
||||
val preview = withContext(Dispatchers.IO) { LocalPreviewLoader().load(current.file, current.displayName, current.mime) }
|
||||
ensureActive()
|
||||
_state.update { it.copy(preview = preview, loading = false) }
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) {
|
||||
if (generation == token) _state.update { it.copy(loading = false, error = messageFor(e)) }
|
||||
} finally {
|
||||
if (!accepted) staged?.file?.let(store::releaseSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToPage(index: Int) {
|
||||
val current = _state.value
|
||||
val document = current.document ?: return
|
||||
if (document.kind != FileKind.PDF || index !in 0 until current.pageCount || current.loading) return
|
||||
documentJob?.cancel()
|
||||
documentJob = viewModelScope.launch {
|
||||
processing.withLock {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
try {
|
||||
val page = pdf.render(document.file, index)
|
||||
ensureActive()
|
||||
savedState["pdfPage"] = page.index
|
||||
_state.update { it.copy(bitmap = page.bitmap, page = page.index, pageCount = page.count, loading = false) }
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { _state.update { it.copy(loading = false, error = messageFor(e)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeDocument() {
|
||||
++generation
|
||||
documentJob?.cancel()
|
||||
_state.value.document?.file?.let(store::releaseSnapshot)
|
||||
savedState.remove<String>("sourceUri")
|
||||
savedState.remove<Int>("pdfPage")
|
||||
_state.value = ViewerState(recents = store.recents())
|
||||
}
|
||||
|
||||
fun clearHistoryAndCache() {
|
||||
closeDocument()
|
||||
store.clearHistoryAndCache()
|
||||
_state.value = ViewerState(notice = "History and private cached copies cleared. Saved folders and original files were not changed.")
|
||||
}
|
||||
|
||||
fun refreshRecents() { _state.update { it.copy(recents = store.recents()) } }
|
||||
|
||||
fun forget(uri: String) { store.forget(uri); _state.update { it.copy(recents = store.recents()) } }
|
||||
fun dismissNotice() { _state.update { it.copy(notice = null) } }
|
||||
fun notice(message: String) { _state.update { it.copy(notice = message) } }
|
||||
fun externalOpenError() { _state.update { it.copy(error = "No readable file was supplied. Use Open file or share a file attachment, not a cloud-document link.") } }
|
||||
|
||||
suspend fun shareOriginal(): android.content.Intent? {
|
||||
val document = _state.value.document ?: return null
|
||||
return try { store.shareIntent(document) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { notice(messageFor(e)); null }
|
||||
}
|
||||
|
||||
suspend fun openInOtherApp(): android.content.Intent? {
|
||||
val document = _state.value.document ?: return null
|
||||
return try { store.openExternalIntent(document) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { notice(messageFor(e)); null }
|
||||
}
|
||||
|
||||
fun saveOriginal(uri: Uri, expectedDocumentId: String?) {
|
||||
val document = _state.value.document ?: return
|
||||
if (document.id != expectedDocumentId) { notice("The document changed while choosing a destination. Export was cancelled; an empty destination file may exist."); return }
|
||||
viewModelScope.launch {
|
||||
try { store.saveCopy(document, uri); notice("A copy of the original file was saved.") }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { notice(messageFor(e)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun exportPreview(uri: Uri, expectedDocumentId: String?) {
|
||||
if (_state.value.document?.id != expectedDocumentId) { notice("The document changed while choosing a destination. Export was cancelled; an empty destination file may exist."); return }
|
||||
val preview = _state.value.preview ?: return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val text = withContext(Dispatchers.Default) {
|
||||
buildString {
|
||||
append("Fileworkbench text preview\n\n")
|
||||
preview.warnings.forEach { append("NOTICE: ").append(it).append("\n") }
|
||||
append('\n').append(PreviewText.render(preview))
|
||||
}
|
||||
}
|
||||
store.exportText(text, uri)
|
||||
notice("Preview text was exported. The original file was not changed.")
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { notice(messageFor(e)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageFor(e: Exception): String = when (e) {
|
||||
is PreviewException -> e.message ?: "The file could not be previewed."
|
||||
is SecurityException, is java.io.FileNotFoundException -> "The file is no longer accessible. Select it again using Open file; temporary attachment grants can expire."
|
||||
is java.util.zip.ZipException -> "This is not a readable ZIP/Office package. It may be damaged or encrypted."
|
||||
else -> "The file could not be opened. It may be unsupported, damaged, or unavailable from its storage provider."
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
pdf.close()
|
||||
_state.value.document?.file?.let(store::releaseSnapshot)
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.util.LruCache
|
||||
import dev.fileworkbench.core.browser.BrowserEntry
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
/** Opt-in, visible-row provider thumbnails only; never parse every full document while browsing. */
|
||||
class BrowserThumbnails(context: Context) {
|
||||
private val resolver = context.applicationContext.contentResolver
|
||||
private val permits = Semaphore(2)
|
||||
private val cache = object : LruCache<String, Bitmap>(4 * 1024 * 1024) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
|
||||
}
|
||||
suspend fun load(entry: BrowserEntry): Bitmap? {
|
||||
if (entry.isDirectory || entry.virtual || !entry.thumbnailSupported) return null
|
||||
val key = "${entry.id}:${entry.modifiedMillis}:${entry.size}"
|
||||
cache.get(key)?.let { return it }
|
||||
return permits.withPermit {
|
||||
try {
|
||||
ProviderIo.read { signal ->
|
||||
val bitmap = DocumentsContract.getDocumentThumbnail(resolver, Uri.parse(entry.id), Point(96, 96), signal)
|
||||
if (bitmap != null && bitmap.allocationByteCount <= 1024 * 1024) cache.put(key, bitmap)
|
||||
bitmap?.takeIf { it.allocationByteCount <= 1024 * 1024 }
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) { null }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
fun clear() { cache.evictAll() }
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.fileworkbench.android.MainActivity
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import dev.fileworkbench.core.browser.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class BrowserState(
|
||||
val locations: List<BrowserLocation> = emptyList(),
|
||||
val location: BrowserLocation? = null,
|
||||
val trail: List<BrowserEntry> = emptyList(),
|
||||
val listing: BrowserListing = BrowserListing(emptyList()),
|
||||
val options: BrowserOptions = BrowserOptions(),
|
||||
val thumbnails: Boolean = false,
|
||||
val recentsTab: Boolean = false,
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val notice: String? = null,
|
||||
val picker: PickerRequest? = null,
|
||||
val selected: List<BrowserEntry> = emptyList(),
|
||||
val exporting: Boolean = false,
|
||||
val result: Intent? = null,
|
||||
val outgoing: Intent? = null,
|
||||
) {
|
||||
val directory: BrowserEntry? get() = trail.lastOrNull()
|
||||
}
|
||||
|
||||
class BrowserViewModel(application: Application, private val savedState: SavedStateHandle) : AndroidViewModel(application) {
|
||||
private val store = SafBrowserStore(application)
|
||||
private val picked = PickedFileStore(application)
|
||||
val thumbnailStore = BrowserThumbnails(application)
|
||||
private val preferences = application.getSharedPreferences("browser_preferences", 0)
|
||||
private val _state = MutableStateFlow(BrowserState())
|
||||
val state = _state.asStateFlow()
|
||||
private var navigationJob: Job? = null
|
||||
private var exportJob: Job? = null
|
||||
private var started = false
|
||||
|
||||
init {
|
||||
val roots = store.locations()
|
||||
val options = BrowserOptions(
|
||||
query = savedState.get<String>("browser.query").orEmpty(),
|
||||
sort = runCatching { BrowserSort.valueOf(preferences.getString("sort", "NAME")!!) }.getOrDefault(BrowserSort.NAME),
|
||||
descending = preferences.getBoolean("descending", false), showHidden = preferences.getBoolean("hidden", false))
|
||||
val location = roots.firstOrNull { it.id == savedState.get<String>("browser.root") }
|
||||
val ids = savedState.get<ArrayList<String>>("browser.path.ids").orEmpty()
|
||||
val names = savedState.get<ArrayList<String>>("browser.path.names").orEmpty()
|
||||
val trail = if (location != null && ids.isNotEmpty() && ids.size == names.size && ids.size <= BrowserLimits.MAX_DEPTH)
|
||||
ids.mapIndexed { index, id -> BrowserEntry(id, names[index], DocumentsContract.Document.MIME_TYPE_DIR, isDirectory = true) }
|
||||
else location?.let { listOf(store.rootEntry(it)) }.orEmpty()
|
||||
_state.value = BrowserState(locations = roots, location = location, trail = trail, options = options,
|
||||
thumbnails = preferences.getBoolean("thumbnails", false), recentsTab = savedState["browser.recents"] ?: false)
|
||||
// Defer restoration until the host configures picker restrictions (notably LOCAL_ONLY).
|
||||
}
|
||||
|
||||
fun start(picker: PickerRequest? = null) {
|
||||
if (started) return
|
||||
started = true
|
||||
// Do not persist this marker: a new VM must requery the provider after process death.
|
||||
_state.update { it.copy(picker = picker, selected = restoreSelection().takeIf { picker != null }.orEmpty(),
|
||||
recentsTab = if (picker != null) false else it.recentsTab) }
|
||||
if (picker?.localOnly == true) {
|
||||
locationsHome()
|
||||
} else if (_state.value.directory != null) refresh()
|
||||
if (picker != null && !picker.isValid) notice("The requesting app supplied invalid file-type filters. Cancel and try another picker.")
|
||||
}
|
||||
|
||||
fun addFolder(uri: Uri, flags: Int, initialDirectory: Uri? = null) {
|
||||
if (_state.value.picker?.localOnly == true) { notice("Use Choose file for a local-only request."); return }
|
||||
navigationJob?.cancel()
|
||||
navigationJob = viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
try {
|
||||
val location = store.addTree(uri, flags)
|
||||
ensureActive()
|
||||
val rootEntry = store.rootEntry(location)
|
||||
val start = initialDirectory?.takeIf { it.toString() != rootEntry.id }?.let { store.metadata(it) }
|
||||
require(start == null || start.isDirectory) { "The folder link does not point to a directory." }
|
||||
val trail = listOf(rootEntry) + listOfNotNull(start)
|
||||
_state.update { it.copy(locations = (store.locations() + location).distinctBy { item -> item.id },
|
||||
location = location, trail = trail, listing = BrowserListing(emptyList()),
|
||||
options = it.options.copy(query = ""), recentsTab = false) }
|
||||
saveNavigation()
|
||||
val listing = store.list(location, trail.last().id)
|
||||
ensureActive()
|
||||
_state.update { it.copy(listing = listing, loading = false,
|
||||
notice = if (!location.persistent) "This folder is available temporarily; the provider did not retain read access." else null) }
|
||||
} catch (e: TimeoutCancellationException) { failed("The storage provider timed out. Retry or choose another location.") }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { failed(message(e)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun receiveDirectory(uri: Uri, flags: Int) {
|
||||
locationsHome()
|
||||
if (uri.scheme != "content" || !UriReadGrants.isTree(uri) || flags and Intent.FLAG_GRANT_READ_URI_PERMISSION == 0) {
|
||||
notice("This folder link does not include a browsable Android tree grant. Use Add folder and select the folder once.")
|
||||
return
|
||||
}
|
||||
val target = uri.takeIf { DocumentsContract.isDocumentUri(getApplication<Application>(), it) }
|
||||
addFolder(uri, flags, target)
|
||||
}
|
||||
|
||||
fun refreshLocations() {
|
||||
val remembered = store.locations()
|
||||
val active = _state.value.location
|
||||
if (active != null && active.persistent && remembered.none { it.id == active.id }) locationsHome()
|
||||
val temporary = active?.takeIf { !it.persistent && remembered.none { item -> item.id == it.id } }
|
||||
_state.update { it.copy(locations = remembered + listOfNotNull(temporary)) }
|
||||
}
|
||||
|
||||
fun enter(location: BrowserLocation) {
|
||||
if (_state.value.exporting || _state.value.picker?.localOnly == true) return
|
||||
_state.update { it.copy(location = location, trail = listOf(store.rootEntry(location)), listing = BrowserListing(emptyList()),
|
||||
options = it.options.copy(query = ""), recentsTab = false, error = null) }
|
||||
saveNavigation(); refresh()
|
||||
}
|
||||
fun enter(entry: BrowserEntry) {
|
||||
val current = _state.value
|
||||
if (!entry.isDirectory || current.location == null || current.exporting || current.loading) return
|
||||
if (current.listing.entries.none { it.id == entry.id && it.isDirectory }) return
|
||||
if (current.trail.size >= BrowserLimits.MAX_DEPTH || current.trail.any { it.id == entry.id }) {
|
||||
notice("This folder is too deeply nested, or the provider returned a folder loop."); return
|
||||
}
|
||||
_state.update { it.copy(trail = it.trail + entry, listing = BrowserListing(emptyList()), options = it.options.copy(query = "")) }
|
||||
saveNavigation(); refresh()
|
||||
}
|
||||
fun breadcrumb(index: Int) {
|
||||
if (index !in _state.value.trail.indices || _state.value.exporting) return
|
||||
_state.update { it.copy(trail = it.trail.take(index + 1), listing = BrowserListing(emptyList()), options = it.options.copy(query = "")) }
|
||||
saveNavigation(); refresh()
|
||||
}
|
||||
fun up() {
|
||||
if (_state.value.trail.size > 1) breadcrumb(_state.value.trail.lastIndex - 1) else locationsHome()
|
||||
}
|
||||
fun locationsHome() {
|
||||
navigationJob?.cancel()
|
||||
_state.update { it.copy(location = null, trail = emptyList(), listing = BrowserListing(emptyList()), loading = false,
|
||||
error = null, recentsTab = false, options = it.options.copy(query = "")) }
|
||||
saveNavigation()
|
||||
}
|
||||
fun recentTab(value: Boolean) {
|
||||
if (_state.value.picker != null) return
|
||||
_state.update { it.copy(recentsTab = value) }
|
||||
savedState["browser.recents"] = value
|
||||
}
|
||||
fun refresh() {
|
||||
val current = _state.value
|
||||
val location = current.location ?: return
|
||||
val directory = current.directory ?: return
|
||||
navigationJob?.cancel()
|
||||
navigationJob = viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
try {
|
||||
val listing = store.list(location, directory.id)
|
||||
ensureActive()
|
||||
_state.update { it.copy(listing = listing, loading = false) }
|
||||
} catch (e: TimeoutCancellationException) { failed("The storage provider timed out. Retry or choose another location.") }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { failed(message(e)) }
|
||||
}
|
||||
}
|
||||
fun forget(location: BrowserLocation) {
|
||||
store.forget(location)
|
||||
thumbnailStore.clear()
|
||||
if (_state.value.location?.id == location.id) locationsHome()
|
||||
_state.update { it.copy(locations = store.locations(), selected = emptyList()) }
|
||||
saveSelection()
|
||||
}
|
||||
fun updateOptions(options: BrowserOptions) {
|
||||
val bounded = options.copy(query = options.query.take(BrowserLimits.MAX_QUERY_LENGTH))
|
||||
_state.update { it.copy(options = bounded) }
|
||||
savedState["browser.query"] = bounded.query
|
||||
preferences.edit().putString("sort", bounded.sort.name).putBoolean("descending", bounded.descending)
|
||||
.putBoolean("hidden", bounded.showHidden).apply()
|
||||
}
|
||||
fun setThumbnails(value: Boolean) {
|
||||
_state.update { it.copy(thumbnails = value) }
|
||||
preferences.edit().putBoolean("thumbnails", value).apply()
|
||||
if (!value) thumbnailStore.clear()
|
||||
}
|
||||
fun toggle(entry: BrowserEntry) {
|
||||
if (_state.value.exporting) return
|
||||
val request = _state.value.picker ?: return
|
||||
try { _state.update { it.copy(selected = request.toggle(it.selected, entry)) }; saveSelection() }
|
||||
catch (e: IllegalArgumentException) { notice(e.message ?: "This file cannot be selected.") }
|
||||
}
|
||||
fun clearSelection() { if (!_state.value.exporting) { _state.update { it.copy(selected = emptyList()) }; saveSelection() } }
|
||||
|
||||
/** Called only with the result of a user-mediated ACTION_OPEN_DOCUMENT, never incoming intent extras. */
|
||||
fun selectedBySystem(uris: List<Uri>) {
|
||||
val request = _state.value.picker ?: return
|
||||
if (_state.value.exporting) return
|
||||
if (uris.size > BrowserLimits.MAX_SELECTED || (!request.allowMultiple && uris.size > 1)) {
|
||||
notice("Too many files selected. Choose at most ${if (request.allowMultiple) BrowserLimits.MAX_SELECTED else 1}."); return
|
||||
}
|
||||
navigationJob?.cancel()
|
||||
navigationJob = viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true) }
|
||||
try {
|
||||
val entries = uris.distinct().map { store.metadata(it) }
|
||||
val selected = if (request.allowMultiple) (_state.value.selected + entries).distinctBy { it.id } else entries
|
||||
request.validateSelection(selected)
|
||||
_state.update { it.copy(selected = selected, loading = false) }
|
||||
saveSelection()
|
||||
} catch (e: TimeoutCancellationException) { failed("The storage provider timed out while reading file details.") }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { failed(message(e)) }
|
||||
}
|
||||
}
|
||||
fun confirmSelection() {
|
||||
val current = _state.value
|
||||
val request = current.picker ?: return
|
||||
if (current.exporting || current.loading || current.selected.isEmpty()) return
|
||||
exportJob = viewModelScope.launch {
|
||||
_state.update { it.copy(exporting = true, error = null) }
|
||||
try {
|
||||
// Revalidate MIME/directory/virtual status immediately before copying, not just against stale rows.
|
||||
val entries = current.selected.map { store.metadata(Uri.parse(it.id)) }
|
||||
request.validateSelection(entries)
|
||||
val result = picked.prepare(request, entries)
|
||||
ensureActive()
|
||||
_state.update { it.copy(result = result, exporting = false) }
|
||||
} catch (e: TimeoutCancellationException) { _state.update { it.copy(exporting = false, notice = "The storage provider timed out. Nothing was returned.") } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { _state.update { it.copy(exporting = false, notice = message(e)) } }
|
||||
}
|
||||
}
|
||||
fun openInOtherApp(entry: BrowserEntry) {
|
||||
if (_state.value.exporting) return
|
||||
exportJob = viewModelScope.launch {
|
||||
_state.update { it.copy(exporting = true) }
|
||||
try {
|
||||
val fresh = store.metadata(Uri.parse(entry.id))
|
||||
val result = picked.prepare(PickerRequest(), listOf(fresh))
|
||||
val view = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(result.data, fresh.mimeType)
|
||||
clipData = result.clipData
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(view, "Open in another app").apply {
|
||||
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(getApplication<Application>(), MainActivity::class.java)))
|
||||
}
|
||||
_state.update { it.copy(outgoing = chooser, exporting = false) }
|
||||
} catch (e: TimeoutCancellationException) { _state.update { it.copy(exporting = false, notice = "The storage provider timed out.") } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { _state.update { it.copy(exporting = false, notice = message(e)) } }
|
||||
}
|
||||
}
|
||||
fun dismissOutgoing() { _state.update { it.copy(outgoing = null) } }
|
||||
fun notice(text: String) { _state.update { it.copy(notice = text) } }
|
||||
fun dismissNotice() { _state.update { it.copy(notice = null) } }
|
||||
fun cancelPendingExport() { exportJob?.cancel(); _state.update { it.copy(exporting = false) } }
|
||||
fun clearThumbnailCache() { thumbnailStore.clear() }
|
||||
|
||||
private fun failed(text: String) { _state.update { it.copy(loading = false, error = text, listing = BrowserListing(emptyList())) } }
|
||||
private fun message(e: Exception): String = when (e) {
|
||||
is SecurityException, is java.io.FileNotFoundException -> "Read access is unavailable. Re-add the folder or choose the file again; the storage device or provider may be offline."
|
||||
is PreviewException, is IllegalArgumentException -> e.message ?: "This operation is not supported."
|
||||
else -> "The storage provider could not complete the operation. Retry, reconnect storage, or choose another location."
|
||||
}
|
||||
private fun saveNavigation() {
|
||||
val state = _state.value
|
||||
savedState["browser.root"] = state.location?.id
|
||||
savedState["browser.path.ids"] = ArrayList(state.trail.map { it.id })
|
||||
savedState["browser.path.names"] = ArrayList(state.trail.map { it.name })
|
||||
savedState["browser.query"] = state.options.query
|
||||
savedState["browser.recents"] = state.recentsTab
|
||||
}
|
||||
private fun saveSelection() {
|
||||
val array = JSONArray()
|
||||
_state.value.selected.forEach { array.put(JSONObject().put("id", it.id).put("name", it.name).put("mime", it.mimeType)) }
|
||||
savedState["browser.selection"] = array.toString()
|
||||
}
|
||||
private fun restoreSelection(): List<BrowserEntry> = runCatching {
|
||||
val array = JSONArray(savedState.get<String>("browser.selection") ?: "[]")
|
||||
(0 until minOf(array.length(), BrowserLimits.MAX_SELECTED)).map {
|
||||
val item = array.getJSONObject(it)
|
||||
BrowserEntry(item.getString("id"), item.getString("name"), item.getString("mime"))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
override fun onCleared() { thumbnailStore.clear(); super.onCleared() }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import dev.fileworkbench.core.browser.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** GET_CONTENT returns read-only snapshots, never the app's saved directory/tree grants. */
|
||||
class PickedFileStore(private val context: Context) {
|
||||
private val resolver = context.contentResolver
|
||||
private val directory = File(context.cacheDir, "picked").apply { mkdirs() }
|
||||
|
||||
suspend fun prepare(request: PickerRequest, entries: List<BrowserEntry>): Intent = lock.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
request.validateSelection(entries)
|
||||
val now = System.currentTimeMillis()
|
||||
directory.listFiles()?.filter { now - it.lastModified() > 86_400_000L }?.forEach { it.deleteRecursively() }
|
||||
val existing = directory.listFiles().orEmpty()
|
||||
if (existing.size >= 32) throw PreviewException("Temporary selection cache is full. Clear history and cache before sharing more copies.")
|
||||
val used = directory.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
val session = File(directory, UUID.randomUUID().toString()).apply { mkdirs() }
|
||||
val budget = SelectionCopyBudget()
|
||||
try {
|
||||
val uris = entries.mapIndexed { index, entry ->
|
||||
currentCoroutineContext().ensureActive()
|
||||
require(!entry.isDirectory && !entry.virtual)
|
||||
val uri = Uri.parse(entry.id)
|
||||
require(uri.scheme == "content" && uri.authority != "${context.packageName}.files")
|
||||
// Separate subdirectories also safely preserve equal filenames in a multi-selection.
|
||||
val parent = File(session, index.toString()).apply { mkdirs() }
|
||||
val file = File(parent, safeFileLabel(entry.name))
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
file.outputStream().use { output ->
|
||||
val bytes = ByteArray(32 * 1024)
|
||||
budget.beginFile()
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val count = input.read(bytes)
|
||||
if (count == -1) break
|
||||
budget.consume(count)
|
||||
if (used + budget.totalBytes > 256L * 1024 * 1024)
|
||||
throw PreviewException("Temporary selection cache is full. Clear history and cache first.")
|
||||
output.write(bytes, 0, count)
|
||||
}
|
||||
}
|
||||
} ?: throw PreviewException("A selected file did not provide readable bytes.")
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.files", file).also {
|
||||
SharedMimeTypes.remember(context, it, entry.mimeType)
|
||||
}
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
val clip = ClipData(ClipDescription("Selected files", entries.map { it.mimeType }.distinct().toTypedArray()), ClipData.Item(uris.first()))
|
||||
uris.drop(1).forEach { clip.addItem(ClipData.Item(it)) }
|
||||
Intent().apply {
|
||||
if (uris.size == 1) setDataAndType(uris.first(), entries.first().mimeType)
|
||||
clipData = clip
|
||||
// No WRITE, PREFIX, PERSISTABLE, or original tree URIs are propagated.
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
} catch (e: Exception) { session.deleteRecursively(); throw e }
|
||||
}
|
||||
}
|
||||
companion object { private val lock = Mutex() }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.IntentCompat
|
||||
import dev.fileworkbench.core.browser.BrowserLimits
|
||||
import dev.fileworkbench.core.browser.PickerRequest
|
||||
|
||||
object PickerIntents {
|
||||
fun request(intent: Intent): PickerRequest {
|
||||
require(intent.action == Intent.ACTION_GET_CONTENT) { "Unsupported picker action." }
|
||||
val extras = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList().orEmpty()
|
||||
val request = PickerRequest(intent.type ?: "*/*", extras,
|
||||
intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false), intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false))
|
||||
require(request.isValid) { "Invalid picker MIME request." }
|
||||
return request
|
||||
}
|
||||
|
||||
/** Activity intent extras become SavedStateHandle defaults. Never accept caller-supplied restore/selection keys. */
|
||||
fun sanitizedPickerIntent(original: Intent, request: PickerRequest): Intent = Intent(original).apply {
|
||||
replaceExtras(Bundle())
|
||||
type = request.mimeType
|
||||
putExtra(Intent.EXTRA_LOCAL_ONLY, request.localOnly)
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, request.allowMultiple)
|
||||
if (request.extraMimeTypes.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, request.extraMimeTypes.toTypedArray())
|
||||
}
|
||||
|
||||
fun sanitizedViewerIntent(original: Intent): Intent = Intent(original).apply {
|
||||
replaceExtras(Bundle())
|
||||
if (original.action == Intent.ACTION_SEND) {
|
||||
runCatching { IntentCompat.getParcelableExtra(original, Intent.EXTRA_STREAM, Uri::class.java) }
|
||||
.getOrNull()?.let { putExtra(Intent.EXTRA_STREAM, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** OPEN_DOCUMENT is handled by Android itself, avoiding recursion into our GET_CONTENT activity. */
|
||||
fun openDocument(request: PickerRequest? = null): Intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = request?.mimeType ?: "*/*"
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (request != null) {
|
||||
if (request.extraMimeTypes.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, request.extraMimeTypes.toTypedArray())
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, request.allowMultiple)
|
||||
putExtra(Intent.EXTRA_LOCAL_ONLY, request.localOnly)
|
||||
}
|
||||
}
|
||||
|
||||
fun resultUris(intent: Intent?): List<Uri> {
|
||||
if (intent == null) return emptyList()
|
||||
val uris = mutableListOf<Uri>()
|
||||
intent.data?.let { uris += it }
|
||||
intent.clipData?.let { clip ->
|
||||
// Keep one over the maximum so the caller can reject an oversized result rather than silently truncate.
|
||||
for (index in 0 until minOf(clip.itemCount, BrowserLimits.MAX_SELECTED + 1)) clip.getItemAt(index).uri?.let { uris += it }
|
||||
}
|
||||
return uris.distinct()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.os.CancellationSignal
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.ArrayBlockingQueue
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/** Bounded provider work, cancellable queries, and a UI timeout even if a provider stalls. */
|
||||
internal object ProviderIo {
|
||||
private val executor = ThreadPoolExecutor(3, 3, 30, TimeUnit.SECONDS, ArrayBlockingQueue(32),
|
||||
{ runnable -> Thread(runnable, "fileworkbench-provider").apply { isDaemon = true } },
|
||||
ThreadPoolExecutor.AbortPolicy())
|
||||
|
||||
suspend fun <T> read(block: (CancellationSignal) -> T): T = withTimeout(20_000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val signal = CancellationSignal()
|
||||
try {
|
||||
val future = executor.submit {
|
||||
val result = try { Result.success(block(signal)) } catch (e: Exception) { Result.failure(e) }
|
||||
continuation.resumeWith(result)
|
||||
}
|
||||
continuation.invokeOnCancellation { signal.cancel(); future.cancel(true) }
|
||||
} catch (e: Exception) { continuation.resumeWithException(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import dev.fileworkbench.core.browser.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** One bounded metadata query per directory. No raw paths, recursive scanning, or broad storage grant. */
|
||||
class SafBrowserStore(private val context: Context) : DirectoryReader {
|
||||
private val resolver = context.contentResolver
|
||||
private val preferences = context.getSharedPreferences("browser_locations", Context.MODE_PRIVATE)
|
||||
|
||||
fun locations(): List<BrowserLocation> = runCatching {
|
||||
val values = JSONArray(preferences.getString("items", "[]"))
|
||||
(0 until minOf(values.length(), BrowserLimits.MAX_LOCATIONS)).map { index ->
|
||||
val value = values.getJSONObject(index)
|
||||
val uri = Uri.parse(value.getString("uri"))
|
||||
BrowserLocation(uri.toString(), safeFileLabel(value.getString("name")), UriReadGrants.hasPersistentRead(resolver, uri))
|
||||
}.distinctBy { it.id }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun addTree(uri: Uri, flags: Int): BrowserLocation {
|
||||
validateExternalUri(uri)
|
||||
require(DocumentsContract.isTreeUri(uri)) { "Select a folder using Add folder." }
|
||||
val tree = DocumentsContract.buildTreeDocumentUri(requireNotNull(uri.authority), DocumentsContract.getTreeDocumentId(uri))
|
||||
val old = locations()
|
||||
require(old.any { it.id == tree.toString() } || old.size < BrowserLimits.MAX_LOCATIONS) {
|
||||
"At most ${BrowserLimits.MAX_LOCATIONS} folders can be remembered. Remove an unused folder first."
|
||||
}
|
||||
val root = metadata(rootDocument(tree))
|
||||
require(root.isDirectory) { "The selected item is not a folder." }
|
||||
if (flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0 && flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) {
|
||||
runCatching { resolver.takePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||
}
|
||||
val location = BrowserLocation(tree.toString(), root.label, UriReadGrants.hasPersistentRead(resolver, tree))
|
||||
// Temporary grants are usable in this session, but not misleadingly persisted as durable bookmarks.
|
||||
if (location.persistent) saveLocations((old.filterNot { it.id == location.id } + location).sortedBy { it.label.lowercase() })
|
||||
return location
|
||||
}
|
||||
|
||||
fun forget(location: BrowserLocation) {
|
||||
saveLocations(locations().filterNot { it.id == location.id })
|
||||
runCatching { resolver.releasePersistableUriPermission(Uri.parse(location.id), Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||
}
|
||||
|
||||
fun rootEntry(location: BrowserLocation) = BrowserEntry(
|
||||
rootDocument(Uri.parse(location.id)).toString(), location.label, Document.MIME_TYPE_DIR, isDirectory = true)
|
||||
|
||||
override suspend fun list(location: BrowserLocation, directoryId: String): BrowserListing = ProviderIo.read { signal ->
|
||||
val tree = Uri.parse(location.id)
|
||||
val directory = Uri.parse(directoryId)
|
||||
validateExternalUri(tree)
|
||||
require(DocumentsContract.isTreeUri(tree) && DocumentsContract.isTreeUri(directory) && tree.authority == directory.authority &&
|
||||
DocumentsContract.getTreeDocumentId(tree) == DocumentsContract.getTreeDocumentId(directory)) { "Invalid folder location." }
|
||||
val children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, DocumentsContract.getDocumentId(directory))
|
||||
val rows = ArrayList<BrowserEntry>()
|
||||
var visited = 0
|
||||
var truncated = false
|
||||
var loading = false
|
||||
var information: String? = null
|
||||
// Check the query result itself, not use's result: optional cursor extras may be null.
|
||||
val queryResult = resolver.query(children, PROJECTION, null, null, null, signal)
|
||||
?: throw PreviewException(
|
||||
"[SAF-D1/NULL_CURSOR] Android returned no cursor for the folder listing.\n" +
|
||||
"Provider: ${safeFileLabel(children.authority ?: "unknown").take(160)}\n" +
|
||||
"Android API: ${Build.VERSION.SDK_INT}; retained read grant: " +
|
||||
runCatching { UriReadGrants.hasPersistentRead(resolver, tree).toString() }.getOrDefault("unknown") +
|
||||
".\nA null cursor is not an empty-folder result. This diagnostic does not identify the cause."
|
||||
)
|
||||
queryResult.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
signal.throwIfCanceled()
|
||||
if (visited++ == BrowserLimits.MAX_ENTRIES) { truncated = true; break }
|
||||
val id = cursor.string(Document.COLUMN_DOCUMENT_ID) ?: continue
|
||||
if (id.length > 8192) continue
|
||||
val childUri = DocumentsContract.buildDocumentUriUsingTree(tree, id)
|
||||
rows += entry(cursor, childUri)
|
||||
}
|
||||
loading = cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false)
|
||||
information = cursor.extras.getString(DocumentsContract.EXTRA_INFO)?.let(::safeFileLabel)
|
||||
cursor.extras.getString(DocumentsContract.EXTRA_ERROR)?.let { information = safeFileLabel(it) }
|
||||
}
|
||||
BrowserListing(rows.distinctBy { it.id }, truncated, loading, information)
|
||||
}
|
||||
|
||||
suspend fun metadata(uri: Uri): BrowserEntry = ProviderIo.read { signal ->
|
||||
validateExternalUri(uri)
|
||||
// General content providers need only support OpenableColumns; document providers expose richer metadata.
|
||||
val document = DocumentsContract.isDocumentUri(context, uri)
|
||||
val projection = if (document) PROJECTION else arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
resolver.query(uri, projection, null, null, null, signal)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) throw PreviewException("The file is unavailable from its storage provider.")
|
||||
val name = cursor.string(OpenableColumns.DISPLAY_NAME)?.take(512) ?: "Document"
|
||||
val declared = cursor.string(Document.COLUMN_MIME_TYPE) ?: resolver.getType(uri)
|
||||
entry(cursor, uri, resolvedMime(declared, name))
|
||||
} ?: throw PreviewException("The file's metadata is not available.")
|
||||
}
|
||||
|
||||
private fun entry(cursor: Cursor, uri: Uri, mimeOverride: String? = null): BrowserEntry {
|
||||
val name = cursor.string(Document.COLUMN_DISPLAY_NAME)?.take(512) ?: "Document"
|
||||
val mime = mimeOverride ?: resolvedMime(cursor.string(Document.COLUMN_MIME_TYPE), name)
|
||||
val flags = cursor.long(Document.COLUMN_FLAGS)?.toInt() ?: 0
|
||||
return BrowserEntry(uri.toString(), name, mime,
|
||||
isDirectory = mime == Document.MIME_TYPE_DIR || mime == "inode/directory",
|
||||
size = cursor.long(Document.COLUMN_SIZE)?.takeIf { it >= 0 },
|
||||
modifiedMillis = cursor.long(Document.COLUMN_LAST_MODIFIED)?.takeIf { it > 0 },
|
||||
virtual = flags and Document.FLAG_VIRTUAL_DOCUMENT != 0,
|
||||
thumbnailSupported = flags and Document.FLAG_SUPPORTS_THUMBNAIL != 0)
|
||||
}
|
||||
|
||||
private fun saveLocations(locations: List<BrowserLocation>) {
|
||||
val json = JSONArray()
|
||||
locations.forEach { json.put(JSONObject().put("uri", it.id).put("name", it.label)) }
|
||||
preferences.edit().putString("items", json.toString()).apply()
|
||||
}
|
||||
private fun validateExternalUri(uri: Uri) {
|
||||
require(uri.scheme == "content" && !uri.authority.isNullOrBlank() && uri.authority != "${context.packageName}.files") {
|
||||
"Only external Android content locations are accepted. Choose a folder using Add folder."
|
||||
}
|
||||
}
|
||||
private fun rootDocument(tree: Uri): Uri = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree))
|
||||
private fun Cursor.string(column: String): String? = getColumnIndex(column).takeIf { it >= 0 && !isNull(it) }?.let(::getString)
|
||||
private fun Cursor.long(column: String): Long? = getColumnIndex(column).takeIf { it >= 0 && !isNull(it) }?.let(::getLong)
|
||||
|
||||
companion object {
|
||||
private val PROJECTION = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS)
|
||||
fun resolvedMime(declared: String?, name: String): String {
|
||||
val normalized = declared?.let(MimeTypes::normalize)
|
||||
if (normalized != null && MimeTypes.validPattern(normalized) && '*' !in normalized && normalized != "application/octet-stream") return normalized
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(name.substringAfterLast('.', "").lowercase())
|
||||
?: normalized?.takeIf { MimeTypes.validPattern(it) && '*' !in it } ?: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
|
||||
/** A saved tree grant also covers documents actually provided within that tree. */
|
||||
internal object UriReadGrants {
|
||||
fun hasPersistentRead(resolver: ContentResolver, uri: Uri): Boolean {
|
||||
val tree = runCatching {
|
||||
if (DocumentsContract.isTreeUri(uri)) DocumentsContract.buildTreeDocumentUri(
|
||||
requireNotNull(uri.authority), DocumentsContract.getTreeDocumentId(uri)) else null
|
||||
}.getOrNull()
|
||||
return resolver.persistedUriPermissions.any { it.isReadPermission && (it.uri == uri || it.uri == tree) }
|
||||
}
|
||||
fun isTree(uri: Uri): Boolean = runCatching { DocumentsContract.isTreeUri(uri) }.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.fileworkbench.android.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.fileworkbench.core.browser.MimeTypes
|
||||
|
||||
/** Preserve provider MIME metadata even for extensionless copied files; FileProvider still enforces paths/grants. */
|
||||
class WorkbenchFileProvider : FileProvider() {
|
||||
override fun getType(uri: Uri): String? {
|
||||
val fallback = super.getType(uri) // Validate against FileProvider's canonical-path allowlist first.
|
||||
return context?.let { SharedMimeTypes.lookup(it, uri) } ?: fallback
|
||||
}
|
||||
}
|
||||
|
||||
internal object SharedMimeTypes {
|
||||
private const val PREFS = "shared_mime_types"
|
||||
fun remember(context: Context, uri: Uri, mime: String) {
|
||||
val normalized = MimeTypes.normalize(mime)
|
||||
if (!MimeTypes.validPattern(normalized) || '*' in normalized) return
|
||||
val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val now = System.currentTimeMillis()
|
||||
val records = preferences.all.map { (key, value) -> key to (value as? String)?.substringBefore('|')?.toLongOrNull() }
|
||||
val edit = preferences.edit()
|
||||
records.filter { it.second == null || now - (it.second ?: 0) > 86_400_000L }.forEach { edit.remove(it.first) }
|
||||
if (records.size >= 1024) records.sortedBy { it.second ?: 0 }.take(records.size - 1023).forEach { edit.remove(it.first) }
|
||||
edit.putString(uri.toString(), "$now|$normalized").apply()
|
||||
}
|
||||
fun lookup(context: Context, uri: Uri): String? = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(uri.toString(), null)?.substringAfter('|')?.takeIf { MimeTypes.validPattern(it) && '*' !in it }
|
||||
fun clear(context: Context) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() }
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package dev.fileworkbench.android.data
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.fileworkbench.core.FileKind
|
||||
import dev.fileworkbench.android.MainActivity
|
||||
import dev.fileworkbench.android.browser.UriReadGrants
|
||||
import dev.fileworkbench.android.browser.SharedMimeTypes
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import dev.fileworkbench.core.SafetyPolicy
|
||||
import dev.fileworkbench.platform.LocalPreviewLoader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** The original provider document is NEVER modified. Each open creates a bounded, private snapshot. */
|
||||
data class StagedDocument(
|
||||
val id: String,
|
||||
val source: String,
|
||||
val displayName: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val kind: FileKind,
|
||||
val file: File,
|
||||
val persistentAccess: Boolean,
|
||||
)
|
||||
|
||||
data class RecentDocument(val uri: String, val name: String, val persistent: Boolean, val timestamp: Long)
|
||||
|
||||
class DocumentStore(private val context: Context, private val policy: SafetyPolicy = SafetyPolicy()) {
|
||||
private val resolver = context.contentResolver
|
||||
private val preferences = context.getSharedPreferences("recent_documents", Context.MODE_PRIVATE)
|
||||
private val sessions = File(context.cacheDir, "sessions").apply { mkdirs() }
|
||||
private val shares = File(context.cacheDir, "shared").apply { mkdirs() }
|
||||
private val renders = File(context.cacheDir, "renders").apply { mkdirs() }
|
||||
|
||||
fun cleanOrphanedSessions() {
|
||||
// MainActivity and FilePickerActivity may coexist. Never delete another live viewer's snapshot on creation.
|
||||
sessions.listFiles()?.filter { it.absolutePath !in activeSnapshots && System.currentTimeMillis() - it.lastModified() > 86_400_000L }?.forEach { it.delete() }
|
||||
renders.listFiles()?.filter { System.currentTimeMillis() - it.lastModified() > 86_400_000L }?.forEach { it.delete() }
|
||||
// Previously shared snapshots expire after a day; the receiver may need the grant until then.
|
||||
shares.listFiles()?.filter { System.currentTimeMillis() - it.lastModified() > 86_400_000L }?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
fun persistReadGrant(uri: Uri): Boolean {
|
||||
runCatching { resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||
return UriReadGrants.hasPersistentRead(resolver, uri)
|
||||
}
|
||||
|
||||
suspend fun stage(uri: Uri): StagedDocument = withContext(Dispatchers.IO) {
|
||||
if (uri.scheme != "content" || uri.authority == "${context.packageName}.files")
|
||||
throw PreviewException("Choose a document using the system file picker. Only external content URIs are accepted.")
|
||||
var name = "Document"
|
||||
var declaredSize: Long? = null
|
||||
runCatching {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val ni = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (ni >= 0 && !cursor.isNull(ni)) name = cursor.getString(ni)
|
||||
val si = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (si >= 0 && !cursor.isNull(si)) declaredSize = cursor.getLong(si)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((declaredSize ?: 0) > policy.maxInputBytes) throw PreviewException("File exceeds the ${policy.maxInputBytes / 1024 / 1024} MiB input limit.")
|
||||
name = safeDisplayName(name)
|
||||
val mime = runCatching { resolver.getType(uri) }.getOrNull()?.takeIf { it.contains('/') && it.length < 160 }
|
||||
?: "application/octet-stream"
|
||||
val id = UUID.randomUUID().toString()
|
||||
val snapshot = File(sessions, "$id.bin")
|
||||
activeSnapshots.add(snapshot.absolutePath)
|
||||
try {
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
snapshot.outputStream().use { output ->
|
||||
val buffer = ByteArray(32 * 1024)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val n = input.read(buffer)
|
||||
if (n == -1) break
|
||||
total += n
|
||||
if (total > policy.maxInputBytes) throw PreviewException("File exceeds the input limit.")
|
||||
output.write(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
} ?: throw PreviewException("The storage provider did not supply readable file bytes. Virtual cloud documents must first be exported to a supported file format.")
|
||||
val kind = LocalPreviewLoader(policy).classify(snapshot, name, mime)
|
||||
StagedDocument(id, uri.toString(), name, mime, snapshot.length(), kind, snapshot,
|
||||
UriReadGrants.hasPersistentRead(resolver, uri))
|
||||
} catch (e: Exception) {
|
||||
snapshot.delete()
|
||||
activeSnapshots.remove(snapshot.absolutePath)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun recents(): List<RecentDocument> = runCatching {
|
||||
val array = JSONArray(preferences.getString("items", "[]"))
|
||||
(0 until array.length()).map { i ->
|
||||
val item = array.getJSONObject(i)
|
||||
RecentDocument(item.getString("uri"), item.getString("name"), item.optBoolean("persistent"), item.optLong("timestamp"))
|
||||
}.take(20)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
fun remember(document: StagedDocument) {
|
||||
val old = recents()
|
||||
val item = RecentDocument(document.source, document.displayName, document.persistentAccess, System.currentTimeMillis())
|
||||
val retained = (listOf(item) + old.filterNot { it.uri == item.uri }).take(20)
|
||||
old.filter { previous -> retained.none { it.uri == previous.uri } }.forEach { releaseGrant(it.uri) }
|
||||
storeRecents(retained)
|
||||
}
|
||||
|
||||
fun forget(uri: String) { storeRecents(recents().filterNot { it.uri == uri }); releaseGrant(uri) }
|
||||
|
||||
fun clearHistoryAndCache() {
|
||||
// Folder bookmarks own their tree grants. Clearing recents must not revoke folder access.
|
||||
resolver.persistedUriPermissions.filterNot { UriReadGrants.isTree(it.uri) }.forEach { releaseGrant(it.uri.toString()) }
|
||||
preferences.edit().clear().apply()
|
||||
SharedMimeTypes.clear(context)
|
||||
File(context.cacheDir, "picked").listFiles()?.forEach { it.deleteRecursively() }
|
||||
sessions.listFiles()?.forEach { it.delete() }
|
||||
shares.listFiles()?.forEach { it.deleteRecursively() }
|
||||
renders.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun releaseGrant(value: String) {
|
||||
val uri = Uri.parse(value)
|
||||
if (UriReadGrants.isTree(uri)) return
|
||||
runCatching { resolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||
}
|
||||
|
||||
private fun storeRecents(items: List<RecentDocument>) {
|
||||
val json = JSONArray()
|
||||
items.forEach { json.put(JSONObject().put("uri", it.uri).put("name", it.name).put("persistent", it.persistent).put("timestamp", it.timestamp)) }
|
||||
preferences.edit().putString("items", json.toString()).apply()
|
||||
}
|
||||
|
||||
private suspend fun shareCopyUri(document: StagedDocument): Uri = withContext(Dispatchers.IO) {
|
||||
// Keep at most two 64 MiB share snapshots. Sharing is an explicit user operation.
|
||||
val dirs = shares.listFiles()?.sortedBy { it.lastModified() }.orEmpty()
|
||||
if (dirs.size >= 2) dirs.take(dirs.size - 1).forEach { it.deleteRecursively() }
|
||||
val directory = File(shares, UUID.randomUUID().toString()).apply { mkdirs() }
|
||||
val copy = File(directory, document.displayName)
|
||||
document.file.copyTo(copy)
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.files", copy).also { SharedMimeTypes.remember(context, it, document.mime) }
|
||||
}
|
||||
|
||||
suspend fun shareIntent(document: StagedDocument): Intent {
|
||||
val sharedUri = shareCopyUri(document)
|
||||
return Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
|
||||
type = document.mime
|
||||
putExtra(Intent.EXTRA_STREAM, sharedUri)
|
||||
clipData = ClipData.newRawUri("File", sharedUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}, "Share original file")
|
||||
}
|
||||
|
||||
suspend fun openExternalIntent(document: StagedDocument): Intent {
|
||||
val uri = shareCopyUri(document)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, document.mime)
|
||||
clipData = ClipData.newRawUri("File", uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
return Intent.createChooser(intent, "Open in another app").apply {
|
||||
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(context, MainActivity::class.java)))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCopy(document: StagedDocument, uri: Uri) = withContext(Dispatchers.IO) {
|
||||
resolver.openOutputStream(uri, "w")?.use { output ->
|
||||
document.file.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: throw PreviewException("The destination is not writable.")
|
||||
}
|
||||
|
||||
suspend fun exportText(text: String, uri: Uri) = withContext(Dispatchers.IO) {
|
||||
resolver.openOutputStream(uri, "w")?.bufferedWriter(Charsets.UTF_8)?.use { it.write(text) }
|
||||
?: throw PreviewException("The destination is not writable.")
|
||||
}
|
||||
|
||||
fun releaseSnapshot(file: File) {
|
||||
activeSnapshots.remove(file.absolutePath)
|
||||
file.delete()
|
||||
}
|
||||
companion object {
|
||||
private val activeSnapshots = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
}
|
||||
|
||||
private fun safeDisplayName(value: String): String = value.map { c ->
|
||||
if (c.code < 32 || c == '/' || c == '\\' || c in '\u202A'..'\u202E' || c in '\u2066'..'\u2069') '_' else c
|
||||
}.joinToString("").trim().take(160).takeUnless { it.isEmpty() || it == "." || it == ".." } ?: "Document"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.fileworkbench.android.render
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import java.io.File
|
||||
|
||||
object ImageLoader {
|
||||
fun decode(file: File): Bitmap {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || bounds.outWidth.toLong() * bounds.outHeight > 100_000_000)
|
||||
throw PreviewException("Unsupported image or excessive image dimensions.")
|
||||
var sample = 1
|
||||
while (bounds.outWidth.toLong() / sample * (bounds.outHeight / sample) > 3_000_000 ||
|
||||
bounds.outWidth / sample > 4096 || bounds.outHeight / sample > 4096) sample *= 2
|
||||
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply {
|
||||
inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}) ?: throw PreviewException("The image could not be decoded.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.fileworkbench.android.render
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.graphics.Bitmap
|
||||
import android.os.*
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
data class PdfPage(val bitmap: Bitmap, val index: Int, val count: Int)
|
||||
|
||||
class PdfRenderClient(private val context: Context) : Closeable {
|
||||
private val lock = Mutex()
|
||||
private val sequence = AtomicInteger()
|
||||
private var remote = CompletableDeferred<Messenger>()
|
||||
private var bound = false
|
||||
private val requests = ConcurrentHashMap<Int, CompletableDeferred<Bundle>>()
|
||||
private val receiver = Messenger(Handler(Looper.getMainLooper()) { message ->
|
||||
requests.remove(message.arg1)?.complete(message.data)
|
||||
true
|
||||
})
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) { remote.complete(Messenger(binder)) }
|
||||
override fun onServiceDisconnected(name: ComponentName) = disconnected()
|
||||
override fun onBindingDied(name: ComponentName) = disconnected()
|
||||
override fun onNullBinding(name: ComponentName) = disconnected()
|
||||
}
|
||||
|
||||
private fun disconnected() {
|
||||
val error = PreviewException("The isolated PDF renderer stopped. Reopen the file to retry.")
|
||||
remote.completeExceptionally(error)
|
||||
requests.values.forEach { it.completeExceptionally(error) }; requests.clear()
|
||||
if (bound) runCatching { context.unbindService(connection) }
|
||||
bound = false
|
||||
remote = CompletableDeferred()
|
||||
}
|
||||
|
||||
suspend fun render(file: File, page: Int): PdfPage = lock.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (!bound) {
|
||||
bound = context.bindService(Intent(context, PdfRenderService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||
if (!bound) throw PreviewException("The isolated PDF renderer could not be started.")
|
||||
}
|
||||
}
|
||||
val id = sequence.incrementAndGet()
|
||||
val result = CompletableDeferred<Bundle>()
|
||||
val output = withContext(Dispatchers.IO) {
|
||||
val directory = File(context.cacheDir, "renders").apply { mkdirs() }
|
||||
File.createTempFile("page-", ".rgba", directory)
|
||||
}
|
||||
requests[id] = result
|
||||
try {
|
||||
val response = withTimeout(30_000) {
|
||||
val messenger = remote.await()
|
||||
withContext(Dispatchers.IO) {
|
||||
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { inputFd ->
|
||||
ParcelFileDescriptor.open(output, ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE).use { outputFd ->
|
||||
messenger.send(Message.obtain().apply {
|
||||
what = PdfRenderService.REQUEST_RENDER; arg1 = id; replyTo = receiver
|
||||
data = Bundle().apply {
|
||||
putParcelable("input", inputFd); putParcelable("output", outputFd)
|
||||
putInt("page", page); putInt("width", 1440)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
result.await()
|
||||
}
|
||||
response.getString("error")?.let { throw PreviewException(it) }
|
||||
withContext(Dispatchers.IO) {
|
||||
val width = response.getInt("width")
|
||||
val height = response.getInt("height")
|
||||
if (width !in 1..2048 || height !in 1..4096 || width.toLong() * height > PdfRenderService.MAX_PIXELS)
|
||||
throw PreviewException("Invalid output from PDF renderer.")
|
||||
val count = response.getInt("count")
|
||||
val index = response.getInt("page")
|
||||
if (count < 1 || index !in 0 until count) throw PreviewException("Invalid PDF page metadata.")
|
||||
val expected = width * height * 4
|
||||
if (output.length() != expected.toLong()) throw PreviewException("Incomplete PDF render.")
|
||||
val bytes = ByteArray(expected)
|
||||
output.inputStream().use { input ->
|
||||
var offset = 0
|
||||
while (offset < expected) {
|
||||
val n = input.read(bytes, offset, expected - offset)
|
||||
if (n <= 0) throw PreviewException("Incomplete PDF pixel output.")
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
||||
PdfPage(bitmap, index, count)
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
withContext(NonCancellable + Dispatchers.Main.immediate) { disconnected() }
|
||||
throw PreviewException("PDF rendering exceeded its time limit.")
|
||||
} finally {
|
||||
requests.remove(id)
|
||||
output.delete()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
requests.values.forEach { it.cancel() }; requests.clear()
|
||||
if (bound) runCatching { context.unbindService(connection) }
|
||||
bound = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.fileworkbench.android.render
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.*
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** Separate Android isolated UID. Only passed descriptors are accessible; no file paths or URI grants.
|
||||
* The response is bounded raw pixels, not a PNG that would reintroduce an image parser into the host.
|
||||
*/
|
||||
class PdfRenderService : Service() {
|
||||
private lateinit var thread: HandlerThread
|
||||
private lateinit var messenger: Messenger
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
thread = HandlerThread("pdf-render").apply { start() }
|
||||
messenger = Messenger(Handler(thread.looper) { message ->
|
||||
if (message.what == REQUEST_RENDER) { render(message); true } else false
|
||||
})
|
||||
}
|
||||
override fun onBind(intent: Intent): IBinder = messenger.binder
|
||||
override fun onDestroy() { thread.quitSafely(); super.onDestroy() }
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun render(message: Message) {
|
||||
val reply = Message.obtain().apply { what = RESPONSE_RENDER; arg1 = message.arg1 }
|
||||
val response = Bundle()
|
||||
val input = message.data.getParcelable<ParcelFileDescriptor>("input")
|
||||
val output = message.data.getParcelable<ParcelFileDescriptor>("output")
|
||||
var renderer: PdfRenderer? = null
|
||||
var bitmap: Bitmap? = null
|
||||
try {
|
||||
require(input != null && output != null) { "Missing render descriptors." }
|
||||
renderer = PdfRenderer(input)
|
||||
val count = renderer.pageCount
|
||||
require(count > 0) { "The PDF contains no pages." }
|
||||
val pageIndex = message.data.getInt("page", 0).coerceIn(0, count - 1)
|
||||
renderer.openPage(pageIndex).use { page ->
|
||||
require(page.width > 0 && page.height > 0) { "Invalid PDF page size." }
|
||||
val desiredWidth = message.data.getInt("width", 1440).coerceIn(256, 2048)
|
||||
var scale = desiredWidth.toDouble() / page.width
|
||||
scale = min(scale, 4096.0 / page.height)
|
||||
scale = min(scale, sqrt(MAX_PIXELS.toDouble() / (page.width.toDouble() * page.height)))
|
||||
val width = (page.width * scale).toInt().coerceAtLeast(1)
|
||||
val height = (page.height * scale).toInt().coerceAtLeast(1)
|
||||
val rendered = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap = rendered
|
||||
rendered.eraseColor(Color.WHITE)
|
||||
page.render(rendered, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
require(rendered.rowBytes == width * 4) { "Unsupported bitmap stride." }
|
||||
val pixels = ByteBuffer.allocate(width * height * 4)
|
||||
rendered.copyPixelsToBuffer(pixels)
|
||||
FileOutputStream(output.fileDescriptor).use { it.write(pixels.array()) }
|
||||
response.putInt("width", width); response.putInt("height", height)
|
||||
response.putInt("page", pageIndex); response.putInt("count", count)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
response.putString("error", "This PDF is password-protected or uses an unsupported security scheme.")
|
||||
} catch (_: Exception) {
|
||||
response.putString("error", "The PDF could not be rendered. It may be damaged, unsupported, or exceed the rendering limits.")
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
runCatching { renderer?.close() }
|
||||
if (renderer == null) runCatching { input?.close() }
|
||||
runCatching { output?.close() }
|
||||
}
|
||||
reply.data = response
|
||||
runCatching { message.replyTo?.send(reply) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_RENDER = 1
|
||||
const val RESPONSE_RENDER = 2
|
||||
const val MAX_PIXELS = 3_000_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package dev.fileworkbench.android.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.fileworkbench.android.browser.BrowserState
|
||||
import dev.fileworkbench.android.browser.BrowserThumbnails
|
||||
import dev.fileworkbench.android.browser.BrowserViewModel
|
||||
import dev.fileworkbench.android.data.RecentDocument
|
||||
import dev.fileworkbench.core.FileClassifier
|
||||
import dev.fileworkbench.core.FileKind
|
||||
import dev.fileworkbench.core.browser.*
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun BrowserScreen(
|
||||
state: BrowserState,
|
||||
browser: BrowserViewModel,
|
||||
recents: List<RecentDocument>,
|
||||
onOpenFile: () -> Unit,
|
||||
onAddFolder: () -> Unit,
|
||||
onPreview: (BrowserEntry) -> Unit,
|
||||
onRecent: (RecentDocument) -> Unit,
|
||||
onForgetRecent: (String) -> Unit,
|
||||
) {
|
||||
var removeLocation by remember { mutableStateOf<BrowserLocation?>(null) }
|
||||
val visible = remember(state.listing.entries, state.options, state.picker) {
|
||||
BrowserEntries.visible(state.listing.entries, state.options, state.picker)
|
||||
}
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (state.picker == null) TabRow(selectedTabIndex = if (state.recentsTab) 1 else 0) {
|
||||
Tab(selected = !state.recentsTab, onClick = { browser.recentTab(false) }, text = { Text("Browse") })
|
||||
Tab(selected = state.recentsTab, onClick = { browser.recentTab(true) }, text = { Text("Recent") })
|
||||
}
|
||||
if (state.loading || state.exporting) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
state.error?.let { text ->
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
Row {
|
||||
if (state.location != null) TextButton(onClick = browser::refresh) { Text("Retry") }
|
||||
TextButton(onClick = onOpenFile) { Text("Choose file") }
|
||||
if (state.picker?.localOnly != true) TextButton(onClick = onAddFolder) { Text("Add folder") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.recentsTab && state.picker == null -> RecentFiles(recents, onRecent, onForgetRecent, onOpenFile)
|
||||
state.location == null -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(if (state.picker == null) "Files, not services." else "Choose files to return",
|
||||
style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(if (state.picker == null) "Browse chosen folders, preview documents, and open files without an account. Originals stay unchanged."
|
||||
else "Nothing is returned until you confirm. The requesting app receives read-only copies of the selected files, not access to your folders.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onOpenFile, enabled = !state.exporting && state.picker?.isValid != false) { Text("Open file") }
|
||||
if (state.picker?.localOnly != true) OutlinedButton(onClick = onAddFolder, enabled = !state.exporting) { Text("Add folder") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.picker != null) {
|
||||
item { Text("Requested types: ${state.picker.mimeType}" +
|
||||
if (state.picker.extraMimeTypes.isNotEmpty()) " · ${state.picker.extraMimeTypes.joinToString()}" else "",
|
||||
style = MaterialTheme.typography.bodySmall) }
|
||||
if (state.picker.localOnly) item {
|
||||
Text("Local-only request: choose files through Android's picker. Saved provider folders are not used because their offline availability cannot be established reliably.",
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
if (state.selected.isNotEmpty()) {
|
||||
item { Text("Selected files", style = MaterialTheme.typography.titleMedium) }
|
||||
items(state.selected, key = { "selected:${it.id}" }) { entry ->
|
||||
BrowserFileRow(entry, false, browser.thumbnailStore, selected = true, picker = true,
|
||||
enabled = !state.exporting, onClick = { browser.toggle(entry) }, onPreview = { onPreview(entry) },
|
||||
onExternal = { browser.openInOtherApp(entry) })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.picker?.localOnly != true) {
|
||||
item { Text("Saved folders", style = MaterialTheme.typography.titleLarge) }
|
||||
if (state.locations.isEmpty()) item { Text("Add a folder once to browse its files and subfolders here. Android asks which folder you allow this app to read.") }
|
||||
items(state.locations, key = { "location:${it.id}" }) { location ->
|
||||
OutlinedCard {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f).clickable(enabled = !state.exporting, role = Role.Button) { browser.enter(location) }.padding(16.dp)) {
|
||||
Text(location.label, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (location.persistent) "Folder read access retained" else "Read access may need renewal", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
TextButton(onClick = { removeLocation = location }, enabled = !state.exporting) { Text("Remove") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text("Android 11+ does not permit granting the storage root or the Downloads root as a folder. Use Open file for individual downloads, or add an allowed subfolder. Other apps' private storage remains inaccessible.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
item {
|
||||
Text("A chosen storage provider may use its own network connection. Thumbnails are off until enabled. Fileworkbench 0.2.3 · Browser diagnostics SAF-D1 · read-only",
|
||||
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = browser::locationsHome, enabled = !state.exporting) { Text("Locations") }
|
||||
state.trail.forEachIndexed { index, entry ->
|
||||
Text("/")
|
||||
TextButton(onClick = { browser.breadcrumb(index) }, enabled = !state.exporting) { Text(entry.label, maxLines = 1) }
|
||||
}
|
||||
}
|
||||
OutlinedTextField(value = state.options.query, onValueChange = { browser.updateOptions(state.options.copy(query = it)) },
|
||||
singleLine = true, label = { Text("Search this folder") }, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
trailingIcon = { if (state.options.query.isNotEmpty()) TextButton(onClick = { browser.updateOptions(state.options.copy(query = "")) }) { Text("Clear") } })
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${visible.size} shown / ${state.listing.entries.size} loaded", style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = browser::refresh, enabled = !state.loading && !state.exporting) { Text("Refresh") }
|
||||
BrowserOptionsMenu(state, browser)
|
||||
}
|
||||
if (state.listing.truncated || state.listing.providerLoading || state.listing.information != null) {
|
||||
Text(buildString {
|
||||
if (state.listing.truncated) append("Listing capped at ${BrowserLimits.MAX_ENTRIES} entries. Search and sort apply only to loaded entries; use Open file for the rest. ")
|
||||
if (state.listing.providerLoading) append("The provider is still loading. Press Refresh for more results. ")
|
||||
state.listing.information?.let { append(it) }
|
||||
}, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
if (visible.isEmpty() && !state.loading && state.error == null) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Text(if (state.listing.entries.isEmpty()) "This folder is empty." else "No files match the current search, visibility, or requested file types.")
|
||||
}
|
||||
} else key(state.directory?.id) {
|
||||
val scroll = rememberLazyListState()
|
||||
LazyColumn(Modifier.fillMaxSize(), state = scroll, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(visible, key = { it.id }) { entry ->
|
||||
BrowserFileRow(entry, state.thumbnails, browser.thumbnailStore,
|
||||
selected = state.selected.any { it.id == entry.id }, picker = state.picker != null,
|
||||
enabled = !state.exporting && !state.loading,
|
||||
onClick = {
|
||||
when {
|
||||
entry.isDirectory -> browser.enter(entry)
|
||||
entry.virtual -> browser.notice("This is a virtual document. Export it to a normal file in its provider first.")
|
||||
state.picker != null -> browser.toggle(entry)
|
||||
else -> onPreview(entry)
|
||||
}
|
||||
}, onPreview = { onPreview(entry) }, onExternal = { browser.openInOtherApp(entry) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
removeLocation?.let { location ->
|
||||
AlertDialog(onDismissRequest = { removeLocation = null }, title = { Text("Remove saved folder?") },
|
||||
text = { Text("Remove ${location.label} and release its saved read grant. Original files are not deleted. Recent files that relied on this folder grant may need to be selected again.") },
|
||||
confirmButton = { TextButton(onClick = { browser.forget(location); removeLocation = null }) { Text("Remove") } },
|
||||
dismissButton = { TextButton(onClick = { removeLocation = null }) { Text("Cancel") } })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowserOptionsMenu(state: BrowserState, browser: BrowserViewModel) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
TextButton(onClick = { open = true }) { Text("Options") }
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
BrowserSort.values().forEach { sort ->
|
||||
val label = when (sort) { BrowserSort.NAME -> "Name"; BrowserSort.MODIFIED -> "Modified date"; BrowserSort.SIZE -> "Size"; BrowserSort.TYPE -> "Type" }
|
||||
DropdownMenuItem(text = { Text("Sort: $label${if (state.options.sort == sort) " (selected)" else ""}") },
|
||||
onClick = { browser.updateOptions(state.options.copy(sort = sort)); open = false })
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text(if (state.options.descending) "Use ascending order" else "Use descending order") },
|
||||
onClick = { browser.updateOptions(state.options.copy(descending = !state.options.descending)); open = false })
|
||||
DropdownMenuItem(text = { Text(if (state.options.showHidden) "Hide dotfiles" else "Show dotfiles") },
|
||||
onClick = { browser.updateOptions(state.options.copy(showHidden = !state.options.showHidden)); open = false })
|
||||
DropdownMenuItem(text = { Text(if (state.thumbnails) "Disable thumbnails" else "Enable provider thumbnails") },
|
||||
onClick = { browser.setThumbnails(!state.thumbnails); open = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowserFileRow(
|
||||
entry: BrowserEntry, thumbnails: Boolean, store: BrowserThumbnails,
|
||||
selected: Boolean, picker: Boolean, enabled: Boolean,
|
||||
onClick: () -> Unit, onPreview: () -> Unit, onExternal: () -> Unit,
|
||||
) {
|
||||
var menu by remember(entry.id) { mutableStateOf(false) }
|
||||
var details by remember(entry.id) { mutableStateOf(false) }
|
||||
val bitmap by produceState<Bitmap?>(null, entry.id, entry.modifiedMillis, thumbnails) {
|
||||
value = if (thumbnails) store.load(entry) else null
|
||||
}
|
||||
OutlinedCard {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f).then(
|
||||
if (picker && !entry.isDirectory) Modifier.toggleable(value = selected, enabled = enabled, role = Role.Checkbox, onValueChange = { onClick() })
|
||||
else Modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
).padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (picker && !entry.isDirectory) Checkbox(checked = selected, onCheckedChange = null, enabled = enabled)
|
||||
else if (bitmap != null) Image(bitmap!!.asImageBitmap(), contentDescription = null,
|
||||
modifier = Modifier.size(44.dp), contentScale = ContentScale.Crop)
|
||||
else FileTypeBadge(entry)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(entry.label, maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||
Text(when {
|
||||
entry.isDirectory -> "Folder"
|
||||
entry.virtual -> "Virtual document · export from provider first"
|
||||
else -> "${entry.size?.let(::formatBytes) ?: "Unknown size"} · ${formatDate(entry.modifiedMillis)}"
|
||||
}, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { menu = true }, enabled = enabled) { Text("More") }
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (!entry.isDirectory) {
|
||||
DropdownMenuItem(text = { Text("Preview") }, enabled = !entry.virtual, onClick = { menu = false; onPreview() })
|
||||
DropdownMenuItem(text = { Text("Open in another app") }, enabled = !entry.virtual, onClick = { menu = false; onExternal() })
|
||||
}
|
||||
DropdownMenuItem(text = { Text("File details") }, onClick = { menu = false; details = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (details) AlertDialog(onDismissRequest = { details = false }, title = { Text("File details") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(entry.label, fontWeight = FontWeight.SemiBold)
|
||||
Text("Type: ${entry.mimeType}")
|
||||
Text("Size: ${entry.size?.let(::formatBytes) ?: "Not supplied"}")
|
||||
Text("Modified: ${formatDate(entry.modifiedMillis)}")
|
||||
Text("Location ID: ${entry.id}", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Metadata comes from the storage provider. No filesystem path is inferred. Originals remain read-only.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}, confirmButton = { TextButton(onClick = { details = false }) { Text("Close") } })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileTypeBadge(entry: BrowserEntry) {
|
||||
val label = if (entry.isDirectory) "DIR" else when (FileClassifier.classify(entry.name, entry.mimeType)) {
|
||||
FileKind.PDF -> "PDF"; FileKind.IMAGE -> "IMG"; FileKind.MARKDOWN -> "MD"; FileKind.CSV -> "CSV"
|
||||
FileKind.DOCX, FileKind.ODT -> "DOC"; FileKind.XLSX, FileKind.ODS -> "SHEET"
|
||||
FileKind.PPTX, FileKind.ODP -> "SLIDE"; FileKind.ZIP -> "ZIP"; FileKind.TEXT -> "TXT"
|
||||
else -> "FILE"
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(10.dp), color = if (entry.isDirectory) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { Text(label, style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentFiles(recents: List<RecentDocument>, onRecent: (RecentDocument) -> Unit, onForget: (String) -> Unit, onOpen: () -> Unit) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Recent files", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onOpen) { Text("Open file") }
|
||||
} }
|
||||
if (recents.isEmpty()) item { Text("Files you preview appear here. Originals remain in their existing storage locations.") }
|
||||
items(recents, key = { it.uri }) { recent ->
|
||||
OutlinedCard {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f).clickable(role = Role.Button) { onRecent(recent) }.padding(16.dp)) {
|
||||
Text(safeFileLabel(recent.name), maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||
Text(if (recent.persistent) "Read grant retained when last opened" else "Temporary access · may need reselection", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
TextButton(onClick = { onForget(recent.uri) }) { Text("Remove") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(timestamp: Long?): String = timestamp?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } ?: "Date unknown"
|
||||
@@ -0,0 +1,168 @@
|
||||
package dev.fileworkbench.android.ui
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.fileworkbench.core.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DocumentReader(preview: DocumentPreview, searchVisible: Boolean, documentId: String) {
|
||||
var selected by rememberSaveable(documentId) { mutableStateOf(0) }
|
||||
var query by rememberSaveable(documentId) { mutableStateOf("") }
|
||||
var warningsExpanded by rememberSaveable(documentId) { mutableStateOf(false) }
|
||||
var hits by remember(documentId) { mutableStateOf(emptyList<SearchHit>()) }
|
||||
val listState = rememberLazyListState()
|
||||
val horizontal = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var highlight by remember(documentId) { mutableStateOf(-1) }
|
||||
var jump by remember(documentId) { mutableStateOf<Int?>(null) }
|
||||
val section = preview.sections.getOrNull(selected)
|
||||
LaunchedEffect(preview, query) {
|
||||
hits = withContext(Dispatchers.Default) { PreviewText.search(preview, query) }
|
||||
}
|
||||
LaunchedEffect(selected, documentId) {
|
||||
listState.scrollToItem(jump ?: 0)
|
||||
jump = null
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (preview.warnings.isNotEmpty()) {
|
||||
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Column(Modifier.fillMaxWidth().clickable { warningsExpanded = !warningsExpanded }.padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Text(if (preview.fidelity == Fidelity.READING_PREVIEW) "Reading preview — not original layout" else "Preview information", fontWeight = FontWeight.SemiBold)
|
||||
if (warningsExpanded) preview.warnings.forEach { Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 6.dp)) }
|
||||
else Text("Tap to see limitations", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (searchVisible) {
|
||||
OutlinedTextField(value = query, onValueChange = { query = it.take(256) }, label = { Text("Find in extracted text") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp))
|
||||
if (query.isNotBlank()) {
|
||||
Text("${hits.size}${if (hits.size >= 1000) "+" else ""} matching blocks / rows", style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
||||
items(hits) { hit ->
|
||||
ListItem(headlineContent = { Text(hit.snippet, maxLines = 2) },
|
||||
supportingContent = { Text(preview.sections.getOrNull(hit.sectionIndex)?.title ?: "Archive") },
|
||||
modifier = Modifier.clickable {
|
||||
if (selected == hit.sectionIndex) scope.launch { listState.scrollToItem(hit.itemIndex + if (section?.table != null) 1 else 0) }
|
||||
else {
|
||||
selected = hit.sectionIndex
|
||||
jump = hit.itemIndex + if (preview.sections.getOrNull(hit.sectionIndex)?.table != null) 1 else 0
|
||||
}
|
||||
highlight = hit.itemIndex
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preview.sections.size > 1) {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
preview.sections.forEachIndexed { index, item ->
|
||||
FilterChip(selected = index == selected, onClick = { selected = index; highlight = -1 }, label = { Text(item.title) })
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
preview.kind == FileKind.ZIP -> LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(preview.entries) { index, entry ->
|
||||
ListItem(headlineContent = { Text(entry.path, fontFamily = FontFamily.Monospace) },
|
||||
supportingContent = { Text(if (entry.directory) "Directory" else "${formatBytes(entry.size)} expanded · ${formatBytes(entry.compressedSize)} compressed") },
|
||||
colors = ListItemDefaults.colors(containerColor = if (index == highlight) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent))
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (preview.entries.isEmpty()) item { Text("Empty ZIP archive.", Modifier.padding(20.dp)) }
|
||||
}
|
||||
section?.table != null -> {
|
||||
val table = section.table!!
|
||||
val columns = table.columnCount
|
||||
Box(Modifier.fillMaxSize().horizontalScroll(horizontal)) {
|
||||
LazyColumn(state = listState, modifier = Modifier.width((56 + columns * 152).coerceAtLeast(240).dp).fillMaxHeight()) {
|
||||
stickyHeader {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Row {
|
||||
TableCellText("Row", 56, header = true)
|
||||
repeat(columns) { TableCellText(columnLabel(it), 152, header = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(table.rows) { index, row ->
|
||||
val cellMap = remember(row) { row.cells.associateBy { it.column } }
|
||||
Row(Modifier.background(if (index == highlight) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent)) {
|
||||
TableCellText(row.number.toString(), 56, header = true)
|
||||
repeat(columns) { column ->
|
||||
val cell = cellMap[column]
|
||||
TableCellText(cell?.value ?: "", 152, formula = cell?.formula)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (table.rows.isEmpty()) item { Text("No cell values in this sheet.", Modifier.padding(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
section != null -> LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
itemsIndexed(section.blocks) { index, block ->
|
||||
SelectionContainer {
|
||||
val style = when (block.kind) {
|
||||
BlockKind.HEADING -> if (block.level <= 1) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleLarge
|
||||
BlockKind.CODE, BlockKind.TABLE_ROW -> MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
||||
else -> MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
Text(highlightedText((if (block.kind == BlockKind.LIST_ITEM) "• " else "") + block.text, if (searchVisible) query else "",
|
||||
MaterialTheme.colorScheme.tertiaryContainer), style = style,
|
||||
modifier = Modifier.fillMaxWidth().background(if (index == highlight) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
|
||||
.padding(vertical = if (block.kind == BlockKind.HEADING) 8.dp else 2.dp))
|
||||
}
|
||||
}
|
||||
if (section.blocks.isEmpty()) item { Text("No supported text was found. Images or unsupported content may still exist in the original.") }
|
||||
}
|
||||
else -> Text("No readable sections.", Modifier.padding(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableCellText(value: String, width: Int, header: Boolean = false, formula: String? = null) {
|
||||
SelectionContainer {
|
||||
Column(Modifier.width(width.dp).padding(8.dp)) {
|
||||
Text(value, style = MaterialTheme.typography.bodySmall, fontWeight = if (header) FontWeight.SemiBold else FontWeight.Normal)
|
||||
if (formula != null) Text("Formula: $formula", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun highlightedText(text: String, query: String, color: Color) = buildAnnotatedString {
|
||||
append(text)
|
||||
val needle = query.trim()
|
||||
if (needle.isNotEmpty()) {
|
||||
var index = 0
|
||||
var marks = 0
|
||||
while (index < text.length && marks < 1000) {
|
||||
val next = text.indexOf(needle, startIndex = index, ignoreCase = true)
|
||||
if (next < 0) break
|
||||
addStyle(SpanStyle(background = color, fontWeight = FontWeight.Bold), next, next + needle.length)
|
||||
index = next + needle.length
|
||||
marks++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package dev.fileworkbench.android.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.fileworkbench.android.ViewerViewModel
|
||||
import dev.fileworkbench.core.FileKind
|
||||
import dev.fileworkbench.core.browser.BrowserEntry
|
||||
import dev.fileworkbench.android.browser.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FileworkbenchApp(
|
||||
viewer: ViewerViewModel,
|
||||
browser: BrowserViewModel,
|
||||
onPickerResult: ((Intent) -> Unit)? = null,
|
||||
onPickerCancel: (() -> Unit)? = null,
|
||||
) {
|
||||
val state by viewer.state.collectAsStateWithLifecycle()
|
||||
val files by browser.state.collectAsStateWithLifecycle()
|
||||
val browserUiState = rememberSaveableStateHolder()
|
||||
var integrationHelp by remember { mutableStateOf(false) }
|
||||
var reviewSelection by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
var details by remember { mutableStateOf(false) }
|
||||
var clearConfirmation by remember { mutableStateOf(false) }
|
||||
var search by rememberSaveable(state.document?.id) { mutableStateOf(false) }
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var pendingCopyId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingTextId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val open = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val uris = PickerIntents.resultUris(result.data)
|
||||
if (files.picker != null) {
|
||||
if (uris.isNotEmpty()) { viewer.closeDocument(); browser.selectedBySystem(uris) }
|
||||
} else uris.firstOrNull()?.let { viewer.open(it,
|
||||
persist = (result.data?.flags ?: 0) and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0) }
|
||||
}
|
||||
}
|
||||
val addFolder = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) result.data?.data?.let {
|
||||
viewer.closeDocument()
|
||||
browser.addFolder(it, result.data?.flags ?: 0)
|
||||
}
|
||||
}
|
||||
val openFile: () -> Unit = {
|
||||
runCatching { open.launch(PickerIntents.openDocument(files.picker)) }
|
||||
.onFailure { viewer.notice("The system file picker is unavailable on this device.") }
|
||||
}
|
||||
val chooseFolder: () -> Unit = {
|
||||
runCatching { addFolder.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||
files.directory?.let { putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(it.id)) }
|
||||
}) }.onFailure { viewer.notice("The system folder picker is unavailable on this device.") }
|
||||
}
|
||||
val saveCopy = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) result.data?.data?.let { viewer.saveOriginal(it, pendingCopyId) }
|
||||
}
|
||||
val exportText = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
uri?.let { viewer.exportPreview(it, pendingTextId) }
|
||||
}
|
||||
BackHandler(enabled = state.document != null || state.loading) { viewer.closeDocument() }
|
||||
BackHandler(enabled = state.document == null && !state.loading && files.location != null && !files.recentsTab && !files.exporting) { browser.up() }
|
||||
BackHandler(enabled = files.exporting) { browser.cancelPendingExport() }
|
||||
LaunchedEffect(files.result) { files.result?.let { onPickerResult?.invoke(it) } }
|
||||
LaunchedEffect(files.notice) {
|
||||
files.notice?.let { snackbar.showSnackbar(it); browser.dismissNotice() }
|
||||
}
|
||||
LaunchedEffect(files.outgoing) {
|
||||
files.outgoing?.let {
|
||||
runCatching { context.startActivity(it) }.onFailure { viewer.notice("No other application could open this file.") }
|
||||
browser.dismissOutgoing()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.notice) {
|
||||
state.notice?.let { snackbar.showSnackbar(it); viewer.dismissNotice() }
|
||||
}
|
||||
|
||||
FileworkbenchTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(state.document?.displayName ?: if (files.picker != null) "Choose files" else "Fileworkbench", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (state.document != null) Text("Read-only • ${formatBytes(state.document!!.size)}", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (state.document != null || state.loading) TextButton(onClick = viewer::closeDocument) { Text("Files") }
|
||||
else if (files.location != null && !files.recentsTab) TextButton(onClick = browser::up) { Text("Up") }
|
||||
},
|
||||
actions = {
|
||||
if (state.preview != null) TextButton(onClick = { search = !search }) { Text(if (search) "Done" else "Search") }
|
||||
Box {
|
||||
TextButton(onClick = { menu = true }) { Text("More") }
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(text = { Text("Open file") }, enabled = !files.exporting, onClick = { menu = false; openFile() })
|
||||
if (files.picker?.localOnly != true) DropdownMenuItem(text = { Text("Add folder") }, enabled = !files.exporting,
|
||||
onClick = { menu = false; chooseFolder() })
|
||||
if (state.document != null) {
|
||||
DropdownMenuItem(text = { Text("File details") }, onClick = { menu = false; details = true })
|
||||
if (files.picker == null) DropdownMenuItem(text = { Text("Open in another app") }, enabled = !state.loading, onClick = {
|
||||
menu = false
|
||||
scope.launch { viewer.openInOtherApp()?.let { intent ->
|
||||
runCatching { context.startActivity(intent) }.onFailure { viewer.notice("No other application could open this file.") }
|
||||
} }
|
||||
})
|
||||
if (files.picker == null) DropdownMenuItem(text = { Text("Share original") }, enabled = !state.loading, onClick = {
|
||||
menu = false
|
||||
scope.launch {
|
||||
viewer.shareOriginal()?.let { intent ->
|
||||
runCatching { context.startActivity(intent) }.onFailure { viewer.notice("No application could receive the shared file.") }
|
||||
}
|
||||
}
|
||||
})
|
||||
if (files.picker == null) DropdownMenuItem(text = { Text("Save a copy") }, enabled = !state.loading, onClick = {
|
||||
menu = false
|
||||
state.document?.let { document -> pendingCopyId = document.id; saveCopy.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = document.mime
|
||||
putExtra(Intent.EXTRA_TITLE, document.displayName)
|
||||
}) }
|
||||
})
|
||||
if (files.picker == null && state.preview != null) DropdownMenuItem(text = { Text("Export preview as text") }, onClick = {
|
||||
menu = false
|
||||
pendingTextId = state.document?.id
|
||||
exportText.launch((state.document?.displayName ?: "Document") + ".preview.txt")
|
||||
})
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text("Browser integration") }, onClick = { menu = false; integrationHelp = true })
|
||||
if (files.picker == null) DropdownMenuItem(text = { Text("Clear history & cache") }, onClick = { menu = false; clearConfirmation = true })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
bottomBar = {
|
||||
if (files.picker != null) Surface(tonalElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
state.document?.let { document ->
|
||||
val entry = BrowserEntry(document.source, document.displayName,
|
||||
SafBrowserStore.resolvedMime(document.mime, document.displayName), size = document.size)
|
||||
if (files.picker!!.accepts(entry)) TextButton(onClick = { browser.toggle(entry) }, enabled = !files.exporting && !state.loading) {
|
||||
Text(if (files.selected.any { it.id == entry.id }) "Deselect previewed file" else "Select previewed file")
|
||||
}
|
||||
}
|
||||
Text(if (files.exporting) "Preparing read-only copies… Back cancels." else "${files.selected.size} selected · read-only copies · 64 MiB/file",
|
||||
style = MaterialTheme.typography.labelMedium)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { browser.cancelPendingExport(); onPickerCancel?.invoke() }) { Text("Cancel") }
|
||||
TextButton(onClick = { reviewSelection = true }, enabled = files.selected.isNotEmpty() && !files.exporting) { Text("Review") }
|
||||
Button(onClick = browser::confirmSelection,
|
||||
enabled = files.selected.isNotEmpty() && !files.loading && !files.exporting && files.picker?.isValid == true) {
|
||||
Text(if (files.selected.size == 1) "Use file" else "Use files")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
if (state.loading) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
state.error?.let { message ->
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text("Cannot display this file", fontWeight = FontWeight.SemiBold)
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium)
|
||||
TextButton(onClick = { openFile() }) { Text("Choose a file") }
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.document == null -> browserUiState.SaveableStateProvider("browser") {
|
||||
BrowserScreen(files, browser, state.recents, onOpenFile = openFile, onAddFolder = chooseFolder,
|
||||
onPreview = { viewer.open(Uri.parse(it.id)) }, onRecent = { viewer.open(Uri.parse(it.uri)) }, onForgetRecent = viewer::forget)
|
||||
}
|
||||
state.preview != null -> DocumentReader(preview = state.preview!!, searchVisible = search, documentId = state.document!!.id)
|
||||
state.bitmap != null -> RasterViewer(state, onPage = viewer::goToPage)
|
||||
!state.loading && state.error == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No preview available.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (details && state.document != null) {
|
||||
val document = state.document!!
|
||||
AlertDialog(onDismissRequest = { details = false }, title = { Text("File details") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(document.displayName, fontWeight = FontWeight.SemiBold)
|
||||
Text("Size: ${formatBytes(document.size)} (${document.size} bytes)")
|
||||
Text("Declared MIME type: ${document.mime}")
|
||||
Text("Detected format: ${state.preview?.kind ?: document.kind}")
|
||||
Text("Access: ${if (document.persistentAccess) "provider read grant retained" else "temporary; reopening may require reselection"}")
|
||||
Text("Private read-only snapshot. Sharing sends the original file, not the reading preview.")
|
||||
Text(when (document.kind) {
|
||||
FileKind.PDF -> "PDF rendering runs in a separate isolated Android process. Text search and forms are not implemented."
|
||||
FileKind.IMAGE -> "Images are downsampled for memory safety. Animation and EXIF rotation are not implemented in this version."
|
||||
else -> "Reading previews use the shared Kotlin core. They are not full Office layout renderings."
|
||||
})
|
||||
}
|
||||
}, confirmButton = { TextButton(onClick = { details = false }) { Text("Close") } })
|
||||
}
|
||||
if (integrationHelp) AlertDialog(onDismissRequest = { integrationHelp = false }, title = { Text("File browser integration") },
|
||||
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Fileworkbench advertises Android's files-app category and can answer Get content requests from other apps. Where a chooser is offered, select Fileworkbench.")
|
||||
Text("Android's system Open document and folder-permission pickers are not replaceable by this app. There is no universal default-files-app setting guaranteed across phones.")
|
||||
Text("Directory links require an accessible content tree. Other folder links ask you to add the folder instead of claiming access to raw paths.")
|
||||
Text("The browser is read-only. Saved-folder removal only releases access; file picking returns temporary copies with read permission, not folder grants.")
|
||||
} }, confirmButton = { TextButton(onClick = { integrationHelp = false }) { Text("Close") } })
|
||||
if (reviewSelection) AlertDialog(onDismissRequest = { reviewSelection = false }, title = { Text("Selected files") },
|
||||
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
files.selected.forEach { entry ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(entry.label, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = { browser.toggle(entry) }) { Text("Remove") }
|
||||
}
|
||||
}
|
||||
if (files.selected.isEmpty()) Text("No files selected.")
|
||||
Text("Only these files will be copied to the requesting app. Original files remain unchanged.")
|
||||
} }, confirmButton = { TextButton(onClick = { reviewSelection = false }) { Text("Done") } })
|
||||
if (clearConfirmation) AlertDialog(onDismissRequest = { clearConfirmation = false }, title = { Text("Clear history and cached copies?") },
|
||||
text = { Text("This closes the document, removes private snapshots and picker copies, and releases individual document grants. Saved folder grants stay intact; remove those under Browse. Originals are not deleted. Previously shared copies may stop being accessible.") },
|
||||
confirmButton = { TextButton(onClick = { clearConfirmation = false; viewer.clearHistoryAndCache(); browser.clearThumbnailCache() }) { Text("Clear") } },
|
||||
dismissButton = { TextButton(onClick = { clearConfirmation = false }) { Text("Cancel") } })
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String = when {
|
||||
bytes < 0 -> "Unknown size"
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024L * 1024 -> "${bytes / 1024} KiB"
|
||||
bytes < 1024L * 1024 * 1024 -> "${String.format(java.util.Locale.getDefault(), "%.1f", bytes / (1024.0 * 1024))} MiB"
|
||||
else -> "${String.format(java.util.Locale.getDefault(), "%.1f", bytes / (1024.0 * 1024 * 1024))} GiB"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.fileworkbench.android.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.fileworkbench.android.ViewerState
|
||||
import dev.fileworkbench.core.FileKind
|
||||
|
||||
@Composable
|
||||
fun RasterViewer(state: ViewerState, onPage: (Int) -> Unit) {
|
||||
val bitmap = state.bitmap ?: return
|
||||
var scale by remember(state.document?.id, state.page) { mutableFloatStateOf(1f) }
|
||||
var offset by remember(state.document?.id, state.page) { mutableStateOf(Offset.Zero) }
|
||||
var jump by remember { mutableStateOf(false) }
|
||||
var pageText by rememberSaveable { mutableStateOf("") }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (state.document?.kind == FileKind.PDF) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
TextButton(enabled = state.page > 0 && !state.loading, onClick = { onPage(state.page - 1) }) { Text("Previous") }
|
||||
TextButton(onClick = { pageText = (state.page + 1).toString(); jump = true }) { Text("${state.page + 1} / ${state.pageCount}") }
|
||||
TextButton(enabled = state.page + 1 < state.pageCount && !state.loading, onClick = { onPage(state.page + 1) }) { Text("Next") }
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.pointerInput(state.document?.id, state.page) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(1f, 5f)
|
||||
val maxX = size.width * (scale - 1) / 2f
|
||||
val maxY = size.height * (scale - 1) / 2f
|
||||
offset = Offset((offset.x + pan.x).coerceIn(-maxX, maxX), (offset.y + pan.y).coerceIn(-maxY, maxY))
|
||||
}
|
||||
}, contentAlignment = Alignment.Center) {
|
||||
Image(bitmap = bitmap.asImageBitmap(), contentDescription = if (state.document?.kind == FileKind.PDF) "PDF page ${state.page + 1}" else state.document?.displayName,
|
||||
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().graphicsLayer {
|
||||
scaleX = scale; scaleY = scale; translationX = offset.x; translationY = offset.y
|
||||
})
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("${(scale * 100).toInt()}% · Pinch to zoom", style = MaterialTheme.typography.labelMedium)
|
||||
TextButton(onClick = { scale = 1f; offset = Offset.Zero }) { Text("Reset zoom") }
|
||||
}
|
||||
}
|
||||
if (jump) AlertDialog(onDismissRequest = { jump = false }, title = { Text("Go to page") }, text = {
|
||||
OutlinedTextField(value = pageText, onValueChange = { pageText = it.filter { c -> c.isDigit() }.take(8) }, singleLine = true,
|
||||
label = { Text("Page 1–${state.pageCount}") })
|
||||
}, confirmButton = {
|
||||
TextButton(enabled = pageText.toIntOrNull()?.let { it in 1..state.pageCount } == true && !state.loading,
|
||||
onClick = { pageText.toIntOrNull()?.let { onPage(it - 1) }; jump = false }) { Text("Go") }
|
||||
}, dismissButton = { TextButton(onClick = { jump = false }) { Text("Cancel") } })
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.fileworkbench.android.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val Light = lightColorScheme(
|
||||
primary = Color(0xFF246A67), onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFCEF0E9), onPrimaryContainer = Color(0xFF083E3D),
|
||||
background = Color(0xFFF7F9F8), surface = Color(0xFFF7F9F8),
|
||||
surfaceVariant = Color(0xFFE1EAE6), onSurfaceVariant = Color(0xFF414B47),
|
||||
)
|
||||
private val Dark = darkColorScheme(
|
||||
primary = Color(0xFF9CD2C9), onPrimary = Color(0xFF003734),
|
||||
primaryContainer = Color(0xFF184E48), onPrimaryContainer = Color(0xFFBEF2E8),
|
||||
background = Color(0xFF101615), surface = Color(0xFF101615),
|
||||
surfaceVariant = Color(0xFF303D38), onSurfaceVariant = Color(0xFFC1CEC6),
|
||||
)
|
||||
@Composable
|
||||
fun FileworkbenchTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) Dark else Light, content = content)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
|
||||
<path android:fillColor="#174E4C" android:pathData="M0,0h48v48h-48z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M13,8h15l8,8v24h-23z" />
|
||||
<path android:fillColor="#C3E9E1" android:pathData="M28,8v9h8z" />
|
||||
<path android:strokeColor="#246A67" android:strokeWidth="2" android:pathData="M18,24h13M18,29h13M18,34h8" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Fileworkbench</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<style name="Theme.Fileworkbench" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
<item name="android:colorAccent">#246A67</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="shared" path="shared/" />
|
||||
<cache-path name="picked" path="picked/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user