From cc085b43ded10c97eb670d94e463c79b55f148c6 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Thu, 15 Oct 2026 09:18:10 +0200 Subject: [PATCH] Receive shares and serve file requests, so other apps can reach this one The manifest had nothing but a LAUNCHER filter, so no other app could hand Plain Files anything. It now registers for ACTION_SEND and SEND_MULTIPLE (a bar over the browser: navigate to the folder, then Save here) and for GET_CONTENT and OPEN_DOCUMENT, where tapping a file hands it back through the existing FileProvider. Both reuse the browser instead of a second screen, because both are navigate-to-a-folder with a different verb at the end. data/ gains one port, IncomingSource, to keep a ContentResolver out of the ViewModel the way DeleteRequestFactory keeps one out of the delete path. Shared files are named from the sender OpenableColumns, allocated by the same NameAllocator a paste uses, and deleted again if they cannot be read rather than left as zero-byte stubs. While providing, a tap on a file chooses it but a tap on a folder still navigates, or the user could never reach the file. ACTION_VIEW is deliberately not registered: the app already hands files out through the system chooser, and registering as a viewer would offer Plain Files back to itself. No new permissions - the merged release manifest still declares no INTERNET. --- CHANGELOG.md | 21 +++ README.md | 9 +- app/src/main/AndroidManifest.xml | 40 +++++ .../filemanager/FileManagerApplication.kt | 13 +- .../filemanager/MainActivity.kt | 38 ++++- .../filemanager/data/IncomingSource.kt | 48 ++++++ .../filemanager/ui/browser/BrowserScreen.kt | 86 ++++++++--- .../filemanager/ui/browser/BrowserUiState.kt | 7 + .../ui/browser/BrowserViewModel.kt | 84 +++++++++++ .../filemanager/ui/browser/IncomingBar.kt | 60 ++++++++ .../filemanager/ui/browser/IncomingRequest.kt | 46 ++++++ app/src/main/res/values/strings.xml | 13 ++ .../ui/browser/BrowserViewModelTest.kt | 140 +++++++++++++++++- docs/ROADMAP.md | 1 + docs/STORE_LISTING.md | 6 + 15 files changed, 583 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/IncomingSource.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/IncomingBar.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/IncomingRequest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b95efb..5b086f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,27 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **The app can receive a share, and act as a file picker.** The manifest had nothing but a + `LAUNCHER` filter, so no other app could hand Plain Files anything at all. It now registers + for `ACTION_SEND`/`SEND_MULTIPLE` — a bar appears over the browser, you navigate to the + folder you want, then "Save here" — and for `ACTION_GET_CONTENT`/`OPEN_DOCUMENT`, where + tapping a file hands it back through the existing `FileProvider`. + + Both reuse the browser rather than adding a second screen, because both are the same task + underneath — navigate to a folder — with a different verb at the end. `data/` gains one + port, `IncomingSource`, so a `ContentResolver` stays out of the ViewModel exactly as + `DeleteRequestFactory` keeps one out of the delete path. A shared file is named from the + sender's `OpenableColumns` where it offers one, goes through the same `NameAllocator` a + paste does (so sharing the same file twice cannot overwrite the first copy), and is removed + again if it cannot be read rather than left as a zero-byte stub. While another app is + waiting for a file, tapping a *file* chooses it and tapping a *folder* still navigates — + without that the user could never reach the file they were trying to choose. + + `ACTION_VIEW` is deliberately **not** registered: the app already hands files out through + the system chooser, and registering as a viewer as well would offer Plain Files back to + itself in that chooser. No new permissions — the merged release manifest still declares no + `INTERNET`. + - **Recursive search.** The query box used to filter the folder you were standing in, which can only ever answer "is it here?" — and the usual question is "where did I put it?". It now walks *down* from the current folder (`domain/FileSearch`) and streams matches as they diff --git a/README.md b/README.md index ba50e02..15528d9 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Android 16 (API 36) from 31 August 2026. app/src/main/java/de/softwareapp_hb/filemanager/ ├── core/ Pure-Kotlin engine (no Android imports!) — paths, sorting, formatting, │ search matching, copy/move planning, conflict policy -├── data/ Storage backends (all-files access + SAF), volumes, bookmarks, -│ file operations, archives, deletion +├── data/ Storage backends (all-files access + SAF), volumes, shares coming in +│ from other apps, file operations, archives, deletion ├── domain/ Browser + operation controllers, models, permission state ├── service/ Foreground service for long-running file operations ├── ui/ Compose theme, browser screen, components, ViewModels @@ -139,8 +139,11 @@ UI.** Translated UI text has to be supplied in `values-/`. ## Known limitations -- **"Open with" and Share only work in the all-files mode.** A SAF path is relative to the +- **Sharing a file *out* only works in the all-files mode.** A SAF path is relative to the granted tree rather than a filesystem path, so it cannot be handed to `FileProvider`. + Receiving a share and being picked from another app's chooser work either way: the first + needs no filesystem path, and the second falls back to `RESULT_CANCELED` rather than + returning a URI the caller could not read. - **RAR, CAB, ISO and WIM are refused**, not supported — they need native code, which this project deliberately does not ship. (The Commons Compress jar contains no RAR classes at all.) **7z is different**: Commons Compress reads it in pure Java, so it *is* supported — diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a576b22..e8dc993 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,6 +69,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Save %1$d file into this folder + Save %1$d files into this folder + + Save here + Navigate to a file and tap it to hand it back + Cancel More options Refresh Cancel @@ -140,6 +149,10 @@ Put %1$d item back Put %1$d items back + + Saved %1$d shared file + Saved %1$d shared files + %1$d item could not be processed %1$d items could not be processed diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index b7029bc..a404640 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -2,6 +2,7 @@ package de.softwareapp_hb.filemanager.ui.browser import android.app.PendingIntent import android.content.Intent +import android.net.Uri import androidx.activity.result.IntentSenderRequest import androidx.lifecycle.SavedStateHandle import de.softwareapp_hb.filemanager.core.ConflictPolicy @@ -10,6 +11,7 @@ import de.softwareapp_hb.filemanager.core.SortDirection import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.data.DeleteRequestFactory +import de.softwareapp_hb.filemanager.data.IncomingSource import de.softwareapp_hb.filemanager.data.StorageAccessMode import de.softwareapp_hb.filemanager.data.VolumeInfo import de.softwareapp_hb.filemanager.domain.OperationKind @@ -19,6 +21,7 @@ import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -34,6 +37,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.io.OutputStream @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -59,10 +63,26 @@ class BrowserViewModelTest { override fun stop() = Unit } + /** Bytes per shared URI, and the name the sending app advertises for it. */ + private val sharedBytes = mutableMapOf() + private val sharedNames = mutableMapOf() + + private val fakeIncoming = object : IncomingSource { + override fun suggestedName(uri: Uri): String? = sharedNames[uri] + + override suspend fun writeTo(uri: Uri, destination: OutputStream) { + // `getValue` throws for a URI with no bytes, which is how a refusing sender is + // simulated. + destination.write(sharedBytes.getValue(uri).toByteArray()) + } + } + @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) recordedNotifications.clear() + sharedBytes.clear() + sharedNames.clear() storage = InMemoryStorageSource( roots = listOf(FileEntry(root, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L)), @@ -89,7 +109,8 @@ class BrowserViewModelTest { private fun viewModel( deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, savedState: SavedStateHandle = SavedStateHandle(), - ) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState) + incoming: IncomingSource? = fakeIncoming, + ) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState, incoming) @Test fun `starts on the root list`() { @@ -578,4 +599,121 @@ class BrowserViewModelTest { assertTrue(storage.contains(downloads.child("a.txt"))) assertFalse(storage.contains(downloads.child("renamed.txt"))) } + + // ---------------------------------------------------------------- incoming + + @Test + fun `a shared file is saved into the folder the user chose`() { + val uri = Uri.parse("content://sender/holiday") + sharedNames[uri] = "holiday.jpg" + sharedBytes[uri] = "jpeg bytes" + + val model = viewModel() + model.openDirectory(downloads) + model.onIncomingRequest(IncomingRequest.Receive(listOf(uri))) + + model.receiveInto(downloads) + + assertEquals("jpeg bytes", storage.contentOf(downloads.child("holiday.jpg"))) + assertNull(model.state.value.receiving) + } + + @Test + fun `a shared file never overwrites one already in the folder`() = runBlocking { + val uri = Uri.parse("content://sender/clash") + sharedNames[uri] = "a.txt" + sharedBytes[uri] = "shared bytes" + + val model = viewModel() + model.openDirectory(downloads) + model.onIncomingRequest(IncomingRequest.Receive(listOf(uri))) + + model.receiveInto(downloads) + + assertEquals("first", storage.contentOf(downloads.child("a.txt"))) + val landed = storage.list(downloads).map { it.name }.filter { it != "a.txt" } + assertTrue(landed.any { storage.contentOf(downloads.child(it)) == "shared bytes" }) + } + + @Test + fun `a file the sender cannot name still lands under a usable one`() { + val uri = Uri.parse("content://sender/nameless") + sharedBytes[uri] = "bytes" + + val model = viewModel() + model.openDirectory(downloads) + model.onIncomingRequest(IncomingRequest.Receive(listOf(uri))) + + model.receiveInto(downloads) + + assertEquals("bytes", storage.contentOf(downloads.child("shared-file"))) + } + + @Test + fun `a share that cannot be read leaves nothing behind`() { + val broken = Uri.parse("content://sender/broken") + val fine = Uri.parse("content://sender/fine") + sharedNames[broken] = "broken.txt" + sharedNames[fine] = "fine.txt" + sharedBytes[fine] = "fine" + // `broken` is advertised but has no bytes, so the sending app refuses to open it. + + val model = viewModel() + model.openDirectory(downloads) + model.onIncomingRequest(IncomingRequest.Receive(listOf(broken, fine))) + + model.receiveInto(downloads) + + assertFalse("no zero-byte stub survives", storage.contains(downloads.child("broken.txt"))) + assertEquals("fine", storage.contentOf(downloads.child("fine.txt"))) + } + + @Test + fun `cancelling a share leaves an ordinary browser`() { + val uri = Uri.parse("content://sender/holiday") + sharedBytes[uri] = "bytes" + val model = viewModel() + model.onIncomingRequest(IncomingRequest.Receive(listOf(uri))) + + model.cancelIncoming() + + assertNull(model.state.value.receiving) + } + + @Test + fun `with no reader wired up, a share is not silently pretended`() = runBlocking { + val uri = Uri.parse("content://sender/holiday") + sharedBytes[uri] = "bytes" + val model = viewModel(incoming = null) + model.openDirectory(downloads) + model.onIncomingRequest(IncomingRequest.Receive(listOf(uri))) + + model.receiveInto(downloads) + + assertTrue(storage.list(downloads).none { it.name == "shared-file" }) + } + + @Test + fun `while providing, tapping a file chooses it instead of opening it`() { + val model = viewModel() + model.onIncomingRequest(IncomingRequest.Provide) + model.openDirectory(downloads) + + model.open(model.state.value.entries.single { it.name == "a.txt" }) + + assertEquals(downloads.child("a.txt"), model.state.value.pendingDeliver?.path) + assertEquals("no viewer took over", Overlay.None, model.state.value.overlay) + } + + @Test + fun `while providing, a folder still navigates so the file can actually be reached`() { + val model = viewModel() + model.onIncomingRequest(IncomingRequest.Provide) + model.openDirectory(root) + + model.open(model.state.value.entries.single { it.path == downloads }) + + assertEquals(downloads, model.state.value.path) + assertNull(model.state.value.pendingDeliver) + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 50f96f6..a5a36e1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,6 +28,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | Language / UI | Kotlin + Jetpack Compose | Mirrors the sibling app's proven, installed toolchain; no NDK | | Storage access | `MANAGE_EXTERNAL_STORAGE` first, SAF fallback | Google Play explicitly permits "All files access" for file managers; SAF keeps the app usable if the user declines | | Connectivity | Local-first (no `INTERNET` in v1) | Preserves the "nothing leaves the device" claim and keeps the Data Safety form trivial | +| File sharing | Receives `SEND`/`SEND_MULTIPLE`, serves `GET_CONTENT`/`OPEN_DOCUMENT`, and deliberately does **not** register `ACTION_VIEW` | A file manager should be able to receive a file and to supply one, and neither costs a permission. Registering as a viewer as well would put Plain Files in the "open with" chooser the app itself uses, offering it back to itself | | Monetisation | One-time paid (€4.49; €2.29 launch price), set in the Console | No Billing client, no `BILLING` permission, no purchase code — which is what keeps "nothing leaves the device" true in the shipped APK. Free → paid is impossible after the first upload, so the price must be set beforehand. See [MONETISATION](MONETISATION.md) | ## Deliberately deferred (post-1.0) diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 094ca40..0e7914b 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -97,6 +97,12 @@ ANY SIZE OF SCREEN • Pictures preview at full size beside the list, with their details • The same layout on a phone, a 7-inch tablet and a 10-inch tablet +WORKS WITH YOUR OTHER APPS +• Share a file to Plain Files from anywhere, then pick the folder to drop it in +• Choose Plain Files when another app asks you for a file +• Search a whole folder tree, not just the folder you are looking at +• Delete, rename or move by mistake? Undo it + MANAGE YOUR FILES • Copy, move, rename and delete, one item or many at once • Create folders and edit text files in the built-in editor