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.
This commit is contained in:
2026-10-15 09:18:10 +02:00
parent ffed22c1da
commit cc085b43de
15 changed files with 583 additions and 29 deletions
+21
View File
@@ -38,6 +38,27 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added ### 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 - **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 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 now walks *down* from the current folder (`domain/FileSearch`) and streams matches as they
+6 -3
View File
@@ -37,8 +37,8 @@ Android 16 (API 36) from 31 August 2026.
app/src/main/java/de/softwareapp_hb/filemanager/ app/src/main/java/de/softwareapp_hb/filemanager/
├── core/ Pure-Kotlin engine (no Android imports!) — paths, sorting, formatting, ├── core/ Pure-Kotlin engine (no Android imports!) — paths, sorting, formatting,
│ search matching, copy/move planning, conflict policy │ search matching, copy/move planning, conflict policy
├── data/ Storage backends (all-files access + SAF), volumes, bookmarks, ├── data/ Storage backends (all-files access + SAF), volumes, shares coming in
│ file operations, archives, deletion │ from other apps, file operations, archives, deletion
├── domain/ Browser + operation controllers, models, permission state ├── domain/ Browser + operation controllers, models, permission state
├── service/ Foreground service for long-running file operations ├── service/ Foreground service for long-running file operations
├── ui/ Compose theme, browser screen, components, ViewModels ├── ui/ Compose theme, browser screen, components, ViewModels
@@ -139,8 +139,11 @@ UI.** Translated UI text has to be supplied in `values-<locale>/`.
## Known limitations ## 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`. 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 - **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 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 — all.) **7z is different**: Commons Compress reads it in pure Java, so it *is* supported —
+40
View File
@@ -69,6 +69,46 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!--
Receiving a share. Without these the app is not a share target at all — the
manifest had nothing but LAUNCHER, so no other app could hand it a file.
`*/*` rather than a list of types on purpose: a file manager should accept
anything the user wants to put somewhere, including types it cannot preview.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!--
Supplying a file. `GET_CONTENT` and `OPEN_DOCUMENT` are how other apps ask
for one, and `OPENABLE` is what makes the chooser list this app for a
"choose a file" prompt rather than a "choose a document" one.
`ACTION_VIEW` is deliberately absent: the app already hands files *out*
through the system chooser, and registering as a viewer as well would risk
offering Plain Files back to itself in that chooser.
-->
<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>
<intent-filter>
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
</activity> </activity>
<!-- <!--
@@ -1,7 +1,9 @@
package de.softwareapp_hb.filemanager package de.softwareapp_hb.filemanager
import android.app.Application import android.app.Application
import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
import de.softwareapp_hb.filemanager.data.IncomingSource
import de.softwareapp_hb.filemanager.data.MediaStoreDeleter import de.softwareapp_hb.filemanager.data.MediaStoreDeleter
import de.softwareapp_hb.filemanager.data.StorageProvider import de.softwareapp_hb.filemanager.data.StorageProvider
import de.softwareapp_hb.filemanager.domain.OperationNotifier import de.softwareapp_hb.filemanager.domain.OperationNotifier
@@ -10,9 +12,9 @@ import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier
/** /**
* Application entry point and the app's tiny object graph. * Application entry point and the app's tiny object graph.
* *
* There is no dependency-injection framework here on purpose: three long-lived * There is no dependency-injection framework here on purpose: four long-lived collaborators
* collaborators are built once and handed to the one ViewModel that needs them. Nothing * are built once and handed to the one ViewModel that needs them. Nothing holds a reference
* holds a reference to an Activity, so none of this leaks. * to an Activity, so none of this leaks.
*/ */
class FileManagerApplication : Application() { class FileManagerApplication : Application() {
@@ -28,10 +30,15 @@ class FileManagerApplication : Application() {
lateinit var deleteRequests: DeleteRequestFactory lateinit var deleteRequests: DeleteRequestFactory
private set private set
/** Reads whatever another app shares in. */
lateinit var incoming: IncomingSource
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
storage = StorageProvider(this) storage = StorageProvider(this)
notifier = ServiceOperationNotifier(this) notifier = ServiceOperationNotifier(this)
deleteRequests = MediaStoreDeleter(this).factory() deleteRequests = MediaStoreDeleter(this).factory()
incoming = ContentResolverIncomingSource(contentResolver)
} }
} }
@@ -1,5 +1,6 @@
package de.softwareapp_hb.filemanager package de.softwareapp_hb.filemanager
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -8,22 +9,57 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.ui.browser.BrowserScreen import de.softwareapp_hb.filemanager.ui.browser.BrowserScreen
import de.softwareapp_hb.filemanager.ui.browser.IncomingRequest
import de.softwareapp_hb.filemanager.ui.browser.contentUriFor
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// A share or an "open" request arrives with the launch intent. An ordinary launch
// has neither, and the browser then behaves exactly as it always did — the two
// flows are the same screen with one extra bar on it.
val incoming = IncomingRequest.from(intent)
setContent { setContent {
FileManagerTheme { FileManagerTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
BrowserScreen() BrowserScreen(
incoming = incoming,
onDeliver = ::deliver,
onDone = ::finish,
)
} }
} }
} }
} }
/**
* Hand a file back to whoever asked for it.
*
* A path reached through the SAF fallback is relative to the granted tree rather than a
* filesystem path, so no `content://` URI can be built for it — the caller then gets
* `RESULT_CANCELED`, which is honest, rather than a URI it could not read.
*/
private fun deliver(entry: FileEntry) {
val uri = contentUriFor(this, entry)
if (uri == null) {
setResult(RESULT_CANCELED)
return
}
setResult(
RESULT_OK,
Intent()
.setData(uri)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION),
)
}
} }
@@ -0,0 +1,48 @@
package de.softwareapp_hb.filemanager.data
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStream
/**
* Something another app has handed to this one.
*
* A port rather than a `ContentResolver` on the ViewModel, for the same reason
* [DeleteRequestFactory] is one: it keeps a `Context` out of the ViewModel, and it lets a
* test hand over plain bytes instead of standing up a real document provider.
*/
interface IncomingSource {
/** The name to save [uri] under, before any clash is resolved. `null` when unknown. */
fun suggestedName(uri: Uri): String?
/** Copy [uri]'s bytes into [destination], which the caller has already created. */
suspend fun writeTo(uri: Uri, destination: OutputStream)
}
/**
* The real one, over the platform's content resolver.
*
* A shared file arrives as a `content://` URI owned by whichever app sent it, so the only
* way to its bytes is through the resolver — and the only honest name for it is the one the
* sender advertises through [OpenableColumns].
*/
class ContentResolverIncomingSource(private val resolver: ContentResolver) : IncomingSource {
override fun suggestedName(uri: Uri): String? = runCatching {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
?: uri.lastPathSegment?.trimEnd('/')?.substringAfterLast('/')
}.getOrNull()?.takeIf { it.isNotBlank() }
override suspend fun writeTo(uri: Uri, destination: OutputStream) {
withContext(Dispatchers.IO) {
val input = resolver.openInputStream(uri)
?: throw StorageException("The sending app would not open $uri")
input.use { it.copyTo(destination) }
}
}
}
@@ -4,6 +4,7 @@ import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -70,7 +71,12 @@ import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory)) { fun BrowserScreen(
incoming: IncomingRequest? = null,
onDeliver: (FileEntry) -> Unit = {},
onDone: () -> Unit = {},
viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory),
) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val provider = remember(context) { (context.applicationContext as FileManagerApplication).storage } val provider = remember(context) { (context.applicationContext as FileManagerApplication).storage }
@@ -84,6 +90,17 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
var searchOpen by remember { mutableStateOf(false) } var searchOpen by remember { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
// Taken on once. A recomposition must not re-arm the bar the user just dismissed.
LaunchedEffect(incoming) { viewModel.onIncomingRequest(incoming) }
// Handing a file back closes the screen: whoever asked for it is waiting on a result.
LaunchedEffect(state.pendingDeliver) {
state.pendingDeliver?.let { entry ->
onDeliver(entry)
onDone()
}
}
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(), ActivityResultContracts.RequestMultiplePermissions(),
) { ) {
@@ -229,26 +246,53 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
BrowserContent( Column(
state = state, modifier = Modifier
contentPadding = innerPadding, .fillMaxSize()
crumbLabel = crumbLabel, .padding(innerPadding),
onOpenEntry = viewModel::open, ) {
onFocusEntry = viewModel::focus, // Files shared in: the user still needs to choose a folder, so this sits above
onFocusRoot = viewModel::focusRoot, // the listing rather than replacing it.
onLongPressEntry = viewModel::toggleSelection, state.receiving?.let { uris ->
onOpenRoot = viewModel::openDirectory, IncomingBar(
onNavigate = viewModel::openDirectory, message = pluralStringResource(R.plurals.incoming_save_here, uris.size, uris.size),
onGrantAllFiles = { // No destination until the user is inside a folder.
val missing = provider.missingRuntimePermissions() actionLabel = state.path?.let { stringResource(R.string.incoming_save_action) },
if (missing.isEmpty()) { onAction = { state.path?.let(viewModel::receiveInto) },
context.startActivity(provider.allFilesAccessIntent()) onCancel = viewModel::cancelIncoming,
} else { )
permissionLauncher.launch(missing.toTypedArray()) }
}
}, if (state.providing) {
onPickFolder = { treeLauncher.launch(null) }, IncomingBar(
) message = stringResource(R.string.incoming_pick_hint),
actionLabel = null,
onAction = {},
onCancel = viewModel::cancelIncoming,
)
}
BrowserContent(
state = state,
contentPadding = PaddingValues(0.dp),
crumbLabel = crumbLabel,
onOpenEntry = viewModel::open,
onFocusEntry = viewModel::focus,
onFocusRoot = viewModel::focusRoot,
onLongPressEntry = viewModel::toggleSelection,
onOpenRoot = viewModel::openDirectory,
onNavigate = viewModel::openDirectory,
onGrantAllFiles = {
val missing = provider.missingRuntimePermissions()
if (missing.isEmpty()) {
context.startActivity(provider.allFilesAccessIntent())
} else {
permissionLauncher.launch(missing.toTypedArray())
}
},
onPickFolder = { treeLauncher.launch(null) },
)
}
} }
if (showSort) { if (showSort) {
@@ -1,5 +1,6 @@
package de.softwareapp_hb.filemanager.ui.browser package de.softwareapp_hb.filemanager.ui.browser
import android.net.Uri
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
@@ -138,6 +139,12 @@ data class BrowserUiState(
/** Set when the system should confirm a delete; the screen launches it and clears it. */ /** Set when the system should confirm a delete; the screen launches it and clears it. */
val pendingDeleteRequest: IntentSenderRequest? = null, val pendingDeleteRequest: IntentSenderRequest? = null,
val conflict: ConflictPrompt? = null, val conflict: ConflictPrompt? = null,
/** Files another app has shared in, waiting for the user to pick a folder for them. */
val receiving: List<Uri>? = null,
/** `true` while another app is waiting for a file this one has to supply. */
val providing: Boolean = false,
/** The file the user chose to hand back; the screen returns it and closes. */
val pendingDeliver: FileEntry? = null,
/** The last destructive change the app made, waiting to be taken back. */ /** The last destructive change the app made, waiting to be taken back. */
val undo: Undo? = null, val undo: Undo? = null,
/** A viewer, editor or the storage-usage view taking over the screen. */ /** A viewer, editor or the storage-usage view taking over the screen. */
@@ -22,6 +22,7 @@ import de.softwareapp_hb.filemanager.core.SortSpec
import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.core.TransferAction import de.softwareapp_hb.filemanager.core.TransferAction
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory 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.StorageAccessMode
import de.softwareapp_hb.filemanager.data.StorageLocation import de.softwareapp_hb.filemanager.data.StorageLocation
import de.softwareapp_hb.filemanager.data.StorageSource import de.softwareapp_hb.filemanager.data.StorageSource
@@ -65,6 +66,7 @@ class BrowserViewModel(
private val notifier: OperationNotifier = OperationNotifier.None, private val notifier: OperationNotifier = OperationNotifier.None,
private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
private val savedState: SavedStateHandle = SavedStateHandle(), private val savedState: SavedStateHandle = SavedStateHandle(),
private val incoming: IncomingSource? = null,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(restoredViewState()) private val _state = MutableStateFlow(restoredViewState())
@@ -392,6 +394,14 @@ class BrowserViewModel(
/** Open an entry: folders navigate, images and text open in the built-in viewers. */ /** Open an entry: folders navigate, images and text open in the built-in viewers. */
fun open(entry: FileEntry) { fun open(entry: FileEntry) {
// While another app is waiting for a file, "open" on a *file* means "hand this one
// back". Folders still navigate — without that the user could never reach the file
// they are trying to choose.
if (_state.value.providing && !entry.isDirectory) {
_state.update { it.copy(pendingDeliver = entry) }
return
}
when { when {
entry.isDirectory -> openDirectory(entry.path) entry.isDirectory -> openDirectory(entry.path)
entry.kind == FileKind.IMAGE -> _state.update { it.copy(overlay = Overlay.Image(entry)) } entry.kind == FileKind.IMAGE -> _state.update { it.copy(overlay = Overlay.Image(entry)) }
@@ -616,6 +626,76 @@ class BrowserViewModel(
if (here == null) refresh() else load(here) if (here == null) refresh() else load(here)
} }
// ------------------------------------------------------------------ incoming
/**
* Take on what another app handed over, once, when the screen starts.
*
* The browser is reused for both cases rather than a second screen being built: a user
* choosing where to save a shared file and a user choosing a file to hand back are
* doing the same thing — navigating to a folder — and only the verb at the end differs.
*/
fun onIncomingRequest(request: IncomingRequest?) {
when (request) {
is IncomingRequest.Receive -> _state.update { it.copy(receiving = request.uris) }
IncomingRequest.Provide -> _state.update { it.copy(providing = true) }
null -> Unit
}
}
/** Give up on receiving or providing, leaving an ordinary browser behind. */
fun cancelIncoming() {
_state.update { it.copy(receiving = null, providing = false, pendingDeliver = null) }
}
/**
* Save the shared files into [folder] — wherever the user navigated to.
*
* Names come from the sending app where it offers one, and clashes go through the same
* [NameAllocator] a paste does, so sharing the same file twice cannot overwrite the
* first copy.
*/
fun receiveInto(folder: StoragePath) {
val uris = _state.value.receiving ?: return
val reader = incoming ?: return
val source = storage.activeSource() ?: return
viewModelScope.launch {
_state.update { it.copy(loading = true) }
notifier.start(OperationKind.COPY)
val taken = source.list(folder).mapTo(HashSet()) { it.name }
val failures = ArrayList<OperationFailure>()
var saved = 0
for (uri in uris) {
val base = reader.suggestedName(uri) ?: FALLBACK_RECEIVED_NAME
val name = NameAllocator.uniqueName(base) { it in taken }
val target = folder.child(name)
try {
source.createFile(target)
source.openWrite(target).use { output -> reader.writeTo(uri, output) }
taken += name
saved++
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
// Do not leave a zero-byte stub behind for a share that never arrived.
runCatching { source.delete(target) }
failures += OperationFailure(target, failure.message ?: "It could not be saved")
}
}
notifier.stop()
_state.update { it.copy(loading = false, receiving = null) }
val result = OperationResult(OperationKind.COPY, saved, failures, false)
if (result.succeeded) messagePlural(R.plurals.message_received, saved) else report(result)
load(folder)
}
}
// --------------------------------------------------------------------- undo // --------------------------------------------------------------------- undo
/** /**
@@ -894,6 +974,9 @@ class BrowserViewModel(
private const val KEY_VIEW_MODE = "browser.viewMode" private const val KEY_VIEW_MODE = "browser.viewMode"
private const val KEY_SHOW_HIDDEN = "browser.showHidden" private const val KEY_SHOW_HIDDEN = "browser.showHidden"
/** Used when the sending app offers no name at all for a shared file. */
private const val FALLBACK_RECEIVED_NAME = "shared-file"
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
@@ -903,6 +986,7 @@ class BrowserViewModel(
notifier = app.notifier, notifier = app.notifier,
deleteRequests = app.deleteRequests, deleteRequests = app.deleteRequests,
savedState = createSavedStateHandle(), savedState = createSavedStateHandle(),
incoming = app.incoming,
) )
} }
} }
@@ -0,0 +1,60 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.filemanager.R
/**
* The bar shown while another app is waiting on this one.
*
* It sits *above* the listing rather than over it, because the whole point is that the user
* can still navigate: choosing where a shared file should go is exactly why the browser was
* reused instead of a dedicated dialog.
*/
@Composable
fun IncomingBar(
message: String,
actionLabel: String?,
onAction: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
actionLabel?.let { label ->
TextButton(onClick = onAction) { Text(label) }
}
IconButton(onClick = onCancel) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.incoming_cancel),
)
}
}
}
}
@@ -0,0 +1,46 @@
package de.softwareapp_hb.filemanager.ui.browser
import android.content.Intent
import android.net.Uri
import androidx.core.content.IntentCompat
/**
* What another app asked this one to do.
*
* Both cases reuse the same browser, because both are the same task underneath: the user
* has to navigate to a folder. Only the verb at the end differs — save here, or hand this
* one back.
*/
sealed interface IncomingRequest {
/** Files shared into the app ([Intent.ACTION_SEND] or its multiple counterpart). */
data class Receive(val uris: List<Uri>) : IncomingRequest
/** Another app is waiting for a file (`ACTION_GET_CONTENT` / `ACTION_OPEN_DOCUMENT`). */
data object Provide : IncomingRequest
companion object {
/** Read [intent], or `null` when this is an ordinary launch. */
fun from(intent: Intent?): IncomingRequest? = when (intent?.action) {
Intent.ACTION_SEND ->
receive(listOfNotNull(IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)))
Intent.ACTION_SEND_MULTIPLE ->
receive(IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java).orEmpty())
Intent.ACTION_GET_CONTENT, Intent.ACTION_OPEN_DOCUMENT -> Provide
else -> null
}
/**
* A share with nothing attached is not a receive request.
*
* Some launchers fire `ACTION_SEND` with no stream; treating that as "save zero
* files here" would put a pointless banner over the browser.
*/
private fun receive(uris: List<Uri>): IncomingRequest? =
uris.takeIf { it.isNotEmpty() }?.let(::Receive)
}
}
+13
View File
@@ -55,6 +55,15 @@
<string name="browser_action_select_all">Select all</string> <string name="browser_action_select_all">Select all</string>
<string name="browser_action_clear_selection">Clear selection</string> <string name="browser_action_clear_selection">Clear selection</string>
<string name="browser_action_undo">Undo</string> <string name="browser_action_undo">Undo</string>
<!-- ===== Browser: files another app has handed over ===== -->
<plurals name="incoming_save_here">
<item quantity="one">Save %1$d file into this folder</item>
<item quantity="other">Save %1$d files into this folder</item>
</plurals>
<string name="incoming_save_action">Save here</string>
<string name="incoming_pick_hint">Navigate to a file and tap it to hand it back</string>
<string name="incoming_cancel">Cancel</string>
<string name="browser_action_more">More options</string> <string name="browser_action_more">More options</string>
<string name="browser_action_refresh">Refresh</string> <string name="browser_action_refresh">Refresh</string>
<string name="browser_action_cancel">Cancel</string> <string name="browser_action_cancel">Cancel</string>
@@ -140,6 +149,10 @@
<item quantity="one">Put %1$d item back</item> <item quantity="one">Put %1$d item back</item>
<item quantity="other">Put %1$d items back</item> <item quantity="other">Put %1$d items back</item>
</plurals> </plurals>
<plurals name="message_received">
<item quantity="one">Saved %1$d shared file</item>
<item quantity="other">Saved %1$d shared files</item>
</plurals>
<plurals name="message_failed"> <plurals name="message_failed">
<item quantity="one">%1$d item could not be processed</item> <item quantity="one">%1$d item could not be processed</item>
<item quantity="other">%1$d items could not be processed</item> <item quantity="other">%1$d items could not be processed</item>
@@ -2,6 +2,7 @@ package de.softwareapp_hb.filemanager.ui.browser
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import de.softwareapp_hb.filemanager.core.ConflictPolicy 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.SortKey
import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory 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.StorageAccessMode
import de.softwareapp_hb.filemanager.data.VolumeInfo import de.softwareapp_hb.filemanager.data.VolumeInfo
import de.softwareapp_hb.filemanager.domain.OperationKind 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 de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
@@ -34,6 +37,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.io.OutputStream
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@@ -59,10 +63,26 @@ class BrowserViewModelTest {
override fun stop() = Unit override fun stop() = Unit
} }
/** Bytes per shared URI, and the name the sending app advertises for it. */
private val sharedBytes = mutableMapOf<Uri, String>()
private val sharedNames = mutableMapOf<Uri, String>()
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 @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher()) Dispatchers.setMain(UnconfinedTestDispatcher())
recordedNotifications.clear() recordedNotifications.clear()
sharedBytes.clear()
sharedNames.clear()
storage = InMemoryStorageSource( storage = InMemoryStorageSource(
roots = listOf(FileEntry(root, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L)), roots = listOf(FileEntry(root, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L)),
@@ -89,7 +109,8 @@ class BrowserViewModelTest {
private fun viewModel( private fun viewModel(
deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
savedState: SavedStateHandle = SavedStateHandle(), savedState: SavedStateHandle = SavedStateHandle(),
) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState) incoming: IncomingSource? = fakeIncoming,
) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState, incoming)
@Test @Test
fun `starts on the root list`() { fun `starts on the root list`() {
@@ -578,4 +599,121 @@ class BrowserViewModelTest {
assertTrue(storage.contains(downloads.child("a.txt"))) assertTrue(storage.contains(downloads.child("a.txt")))
assertFalse(storage.contains(downloads.child("renamed.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)
}
} }
+1
View File
@@ -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 | | 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 | | 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 | | 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) | | 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) ## Deliberately deferred (post-1.0)
+6
View File
@@ -97,6 +97,12 @@ ANY SIZE OF SCREEN
• Pictures preview at full size beside the list, with their details • 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 • 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 MANAGE YOUR FILES
• Copy, move, rename and delete, one item or many at once • Copy, move, rename and delete, one item or many at once
• Create folders and edit text files in the built-in editor • Create folders and edit text files in the built-in editor