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:
@@ -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
|
||||
|
||||
@@ -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-<locale>/`.
|
||||
|
||||
## 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 —
|
||||
|
||||
@@ -69,6 +69,46 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.softwareapp_hb.filemanager
|
||||
|
||||
import android.app.Application
|
||||
import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource
|
||||
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.StorageProvider
|
||||
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.
|
||||
*
|
||||
* There is no dependency-injection framework here on purpose: three long-lived
|
||||
* collaborators are built once and handed to the one ViewModel that needs them. Nothing
|
||||
* holds a reference to an Activity, so none of this leaks.
|
||||
* There is no dependency-injection framework here on purpose: four long-lived collaborators
|
||||
* are built once and handed to the one ViewModel that needs them. Nothing holds a reference
|
||||
* to an Activity, so none of this leaks.
|
||||
*/
|
||||
class FileManagerApplication : Application() {
|
||||
|
||||
@@ -28,10 +30,15 @@ class FileManagerApplication : Application() {
|
||||
lateinit var deleteRequests: DeleteRequestFactory
|
||||
private set
|
||||
|
||||
/** Reads whatever another app shares in. */
|
||||
lateinit var incoming: IncomingSource
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
storage = StorageProvider(this)
|
||||
notifier = ServiceOperationNotifier(this)
|
||||
deleteRequests = MediaStoreDeleter(this).factory()
|
||||
incoming = ContentResolverIncomingSource(contentResolver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.softwareapp_hb.filemanager
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -8,22 +9,57 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.IncomingRequest
|
||||
import de.softwareapp_hb.filemanager.ui.browser.contentUriFor
|
||||
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
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 {
|
||||
FileManagerTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -70,7 +71,12 @@ import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@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 context = LocalContext.current
|
||||
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 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(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) {
|
||||
@@ -229,9 +246,35 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// Files shared in: the user still needs to choose a folder, so this sits above
|
||||
// the listing rather than replacing it.
|
||||
state.receiving?.let { uris ->
|
||||
IncomingBar(
|
||||
message = pluralStringResource(R.plurals.incoming_save_here, uris.size, uris.size),
|
||||
// No destination until the user is inside a folder.
|
||||
actionLabel = state.path?.let { stringResource(R.string.incoming_save_action) },
|
||||
onAction = { state.path?.let(viewModel::receiveInto) },
|
||||
onCancel = viewModel::cancelIncoming,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.providing) {
|
||||
IncomingBar(
|
||||
message = stringResource(R.string.incoming_pick_hint),
|
||||
actionLabel = null,
|
||||
onAction = {},
|
||||
onCancel = viewModel::cancelIncoming,
|
||||
)
|
||||
}
|
||||
|
||||
BrowserContent(
|
||||
state = state,
|
||||
contentPadding = innerPadding,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
crumbLabel = crumbLabel,
|
||||
onOpenEntry = viewModel::open,
|
||||
onFocusEntry = viewModel::focus,
|
||||
@@ -250,6 +293,7 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
|
||||
onPickFolder = { treeLauncher.launch(null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showSort) {
|
||||
SortSheet(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.softwareapp_hb.filemanager.ui.browser
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.annotation.AnyRes
|
||||
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. */
|
||||
val pendingDeleteRequest: IntentSenderRequest? = 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. */
|
||||
val undo: Undo? = null,
|
||||
/** 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.TransferAction
|
||||
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.StorageLocation
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
@@ -65,6 +66,7 @@ class BrowserViewModel(
|
||||
private val notifier: OperationNotifier = OperationNotifier.None,
|
||||
private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
|
||||
private val savedState: SavedStateHandle = SavedStateHandle(),
|
||||
private val incoming: IncomingSource? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
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. */
|
||||
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 {
|
||||
entry.isDirectory -> openDirectory(entry.path)
|
||||
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)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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
|
||||
|
||||
/**
|
||||
@@ -894,6 +974,9 @@ class BrowserViewModel(
|
||||
private const val KEY_VIEW_MODE = "browser.viewMode"
|
||||
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 {
|
||||
initializer {
|
||||
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||
@@ -903,6 +986,7 @@ class BrowserViewModel(
|
||||
notifier = app.notifier,
|
||||
deleteRequests = app.deleteRequests,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,15 @@
|
||||
<string name="browser_action_select_all">Select all</string>
|
||||
<string name="browser_action_clear_selection">Clear selection</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_refresh">Refresh</string>
|
||||
<string name="browser_action_cancel">Cancel</string>
|
||||
@@ -140,6 +149,10 @@
|
||||
<item quantity="one">Put %1$d item back</item>
|
||||
<item quantity="other">Put %1$d items back</item>
|
||||
</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">
|
||||
<item quantity="one">%1$d item could not be processed</item>
|
||||
<item quantity="other">%1$d items could not be processed</item>
|
||||
|
||||
+139
-1
@@ -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<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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user