initial commit v 0.2.0
Verify source and build Android / android (push) Canceled after 0s

This commit is contained in:
2026-09-24 22:49:27 +02:00
commit db873063f8
129 changed files with 7512 additions and 0 deletions
+51
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
# No app-specific shrinking rules in the read-only MVP.
@@ -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()
}
}
}
+16
View File
@@ -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>
@@ -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) }
}
+101
View File
@@ -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>