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
+40
View File
@@ -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,26 +246,53 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
BrowserContent(
state = state,
contentPadding = innerPadding,
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) },
)
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 = 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) {
@@ -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)
}
}
+13
View File
@@ -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>
@@ -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)
}
}