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
|
### 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
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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,9 +246,35 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
|
|||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { 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(
|
BrowserContent(
|
||||||
state = state,
|
state = state,
|
||||||
contentPadding = innerPadding,
|
contentPadding = PaddingValues(0.dp),
|
||||||
crumbLabel = crumbLabel,
|
crumbLabel = crumbLabel,
|
||||||
onOpenEntry = viewModel::open,
|
onOpenEntry = viewModel::open,
|
||||||
onFocusEntry = viewModel::focus,
|
onFocusEntry = viewModel::focus,
|
||||||
@@ -250,6 +293,7 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
|
|||||||
onPickFolder = { treeLauncher.launch(null) },
|
onPickFolder = { treeLauncher.launch(null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showSort) {
|
if (showSort) {
|
||||||
SortSheet(
|
SortSheet(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+139
-1
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user