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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user