fix(recordings): open the recordings folder from the system file manager
The recordings live in app-private storage (<data>/app_flutter/grabaciones), which the Android sandbox forbids any other app from reading, so no ACTION_VIEW on a file:// or FileProvider URI could ever open it. On top of that, viewDirectory built an EMPTY candidate list for that path: directoryDocumentUri returned null (path outside external storage) and FileProvider.getUriForFile threw because pluriwave_file_paths.xml never covered app_flutter. The loop never ran, so both entry points -- the radio recorder and Settings -- always showed "could not open the folder". Publish the folder as a browsable storage root via RecordingsDocumentsProvider instead. The files never leave private storage; the document framework asks us for them one document at a time, and the user can browse, copy out, rename and delete straight from the file manager. The root follows a user-configured path and falls back to the default recordings directory. Its title reuses the already-translated recordingsFolderTitle, so no new literal is introduced in any of the 13 locales. Also fixes "open last recording", broken by the same missing FileProvider root, and replaces Intent.createChooser with a bare startActivity in the candidate loop: a chooser never throws when nothing can handle the intent, so the first candidate always "succeeded" and the fallback chain never ran. Device QA pending -- the provider is driven entirely by the platform's document framework, so no unit test covers it. Each candidate logs its own name under file_actions.viewDirectory for logcat triage.
This commit is contained in:
@@ -104,6 +104,23 @@
|
||||
android:resource="@xml/pluriwave_file_paths" />
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
Publishes the app-private recordings folder as a browsable storage
|
||||
root for the system file manager. MANAGE_DOCUMENTS restricts direct
|
||||
access to the document framework (DocumentsUI); grantUriPermissions
|
||||
lets it hand single-file access to whatever app the user picks.
|
||||
-->
|
||||
<provider
|
||||
android:name=".RecordingsDocumentsProvider"
|
||||
android:authorities="${applicationId}.recordings"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
@@ -28,6 +28,7 @@ object AlarmNotificationStrings {
|
||||
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
|
||||
const val KEY_OPEN_FOLDER = "openFolderTitle"
|
||||
const val KEY_OPEN_RECORDING = "openRecordingTitle"
|
||||
const val KEY_RECORDINGS_ROOT_TITLE = "recordingsRootTitle"
|
||||
const val KEY_MISSED_TITLE = "missedTitle"
|
||||
const val KEY_MISSED_TEMPLATE = "missedTemplate"
|
||||
|
||||
@@ -55,6 +56,10 @@ object AlarmNotificationStrings {
|
||||
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
|
||||
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
|
||||
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
|
||||
|
||||
/** Title of the storage root published by [RecordingsDocumentsProvider]. */
|
||||
fun recordingsRootTitle(context: Context) =
|
||||
get(context, KEY_RECORDINGS_ROOT_TITLE, "PluriWave recordings")
|
||||
fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm")
|
||||
fun missedText(context: Context, name: String): String =
|
||||
format(
|
||||
|
||||
@@ -41,6 +41,11 @@ class MainActivity : AudioServiceActivity() {
|
||||
private val bluetoothConnectPermissionRequestCode = 4823
|
||||
private val pickMusicFolderRequestCode = 4824
|
||||
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
|
||||
|
||||
// MIME types DocumentsUI's file browser declares an ACTION_VIEW filter for.
|
||||
// DocumentsContract only exposes the directory one as a constant.
|
||||
private val directoryDocumentMimeType = DocumentsContract.Document.MIME_TYPE_DIR
|
||||
private val rootDocumentMimeType = "vnd.android.document/root"
|
||||
private var visualizer: Visualizer? = null
|
||||
private var pendingSink: EventChannel.EventSink? = null
|
||||
private var pendingArgs: Map<*, *>? = null
|
||||
@@ -784,57 +789,89 @@ class MainActivity : AudioServiceActivity() {
|
||||
return opened
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [path] in whatever app the system uses to browse folders.
|
||||
*
|
||||
* The default recordings folder lives in app-private storage, which NO file
|
||||
* manager can reach through a `file://` or `FileProvider` URI -- the Android
|
||||
* sandbox forbids other apps from reading `/data/user/0/<package>/`. That is
|
||||
* why the old `resource/folder` candidate could never work.
|
||||
* [RecordingsDocumentsProvider] publishes the folder as a document root
|
||||
* instead, so the candidates below hand the system a URI it can actually
|
||||
* resolve without a single byte leaving private storage. A user-configured
|
||||
* folder on shared storage keeps using the platform's own external-storage
|
||||
* provider, which the file manager already indexes.
|
||||
*
|
||||
* `startActivity` is used directly instead of `Intent.createChooser`,
|
||||
* because a chooser never throws when nothing can handle the intent (it just
|
||||
* shows an empty dialog). That swallowed the failure and stopped the
|
||||
* fallback chain from ever running.
|
||||
*/
|
||||
private fun viewDirectory(path: String): Boolean {
|
||||
val directory = File(path)
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
// Point the published root at the folder Flutter is really using, so a
|
||||
// path changed in Settings is what the file manager shows.
|
||||
RecordingsDocumentsProvider.rememberRoot(this, path)
|
||||
|
||||
val candidates = mutableListOf<Intent>()
|
||||
val candidates = mutableListOf<Pair<String, Intent>>()
|
||||
|
||||
// Shared storage: the platform provider already exposes this folder, so
|
||||
// prefer it when the user picked a public path.
|
||||
directoryDocumentUri(path)?.let { uri ->
|
||||
candidates.add(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "vnd.android.document/directory")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
)
|
||||
candidates.add(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setData(uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
)
|
||||
}
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory)
|
||||
candidates.add(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "resource/folder")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error)
|
||||
}
|
||||
|
||||
for (intent in candidates) {
|
||||
try {
|
||||
startActivity(
|
||||
Intent.createChooser(intent, AlarmNotificationStrings.openFolderTitle(this))
|
||||
)
|
||||
Log.d(tag, "file_actions.viewDirectory launched path=$path")
|
||||
return true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error)
|
||||
candidates += "externalstorage-dir" to Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, directoryDocumentMimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
// Our own root: handled by DocumentsUI's file browser on every device
|
||||
// that ships it, and the only option that works for private storage.
|
||||
candidates += "recordings-root" to Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(
|
||||
RecordingsDocumentsProvider.rootUri(this@MainActivity),
|
||||
rootDocumentMimeType
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
candidates += "recordings-dir" to Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(
|
||||
RecordingsDocumentsProvider.rootDocumentUri(this@MainActivity),
|
||||
directoryDocumentMimeType
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
// Last resort: DocumentsUI always handles OPEN_DOCUMENT_TREE, and
|
||||
// EXTRA_INITIAL_URI lands it straight on the recordings folder.
|
||||
candidates += "recordings-tree" to Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(
|
||||
DocumentsContract.EXTRA_INITIAL_URI,
|
||||
RecordingsDocumentsProvider.rootTreeUri(this@MainActivity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for ((origin, intent) in candidates) {
|
||||
if (openIntentSafely(intent, "file_actions.viewDirectory $origin", path, requireData = false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
Log.w(tag, "file_actions.viewDirectory no candidate could be launched path=$path")
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean {
|
||||
if (intent == null || intent.data == null) return false
|
||||
private fun openIntentSafely(
|
||||
intent: Intent?,
|
||||
origin: String,
|
||||
path: String,
|
||||
requireData: Boolean = true,
|
||||
): Boolean {
|
||||
if (intent == null) return false
|
||||
if (requireData && intent.data == null) return false
|
||||
return try {
|
||||
startActivity(intent)
|
||||
Log.d(tag, "$origin launched path=$path")
|
||||
@@ -872,10 +909,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Log.w(tag, "file_actions.openFile no viewer path=$path; opening parent")
|
||||
openDirectory(file.parentFile?.absolutePath ?: path)
|
||||
viewDirectory(file.parentFile?.absolutePath ?: path)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
|
||||
openDirectory(file.parentFile?.absolutePath ?: path)
|
||||
viewDirectory(file.parentFile?.absolutePath ?: path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,6 +933,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
if (!path.startsWith(external)) return null
|
||||
|
||||
val relative = path.removePrefix(external).trimStart('/')
|
||||
// Android 11+ hides Android/data and Android/obb from the document
|
||||
// framework, so a URI into them resolves to nothing useful.
|
||||
if (relative.startsWith("Android/data") || relative.startsWith("Android/obb")) return null
|
||||
|
||||
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
|
||||
return DocumentsContract.buildDocumentUri(
|
||||
"com.android.externalstorage.documents",
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsProvider
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Publishes the radio-recordings folder as a storage root the system file
|
||||
* manager can browse, WITHOUT moving a single file out of app-private storage.
|
||||
*
|
||||
* Why this exists: the recordings live under
|
||||
* `getApplicationDocumentsDirectory()/grabaciones`
|
||||
* (`/data/user/0/es.freetimelab.pluriwave/app_flutter/grabaciones`). The Android
|
||||
* sandbox forbids any other app -- including the system Files app -- from
|
||||
* reading that path, so no `ACTION_VIEW` on a `file://` or `FileProvider` URI
|
||||
* can ever open it. A `DocumentsProvider` is the only supported way to expose
|
||||
* private files to the document framework: we stay the owner of the bytes and
|
||||
* the system asks US for them, one document at a time.
|
||||
*
|
||||
* The root is browsable, readable, writable, renameable and deletable so the
|
||||
* user can do whatever they want with their recordings (copy out, share, delete,
|
||||
* open in another player) straight from the file manager.
|
||||
*
|
||||
* Static-review-only component: it runs in the app process but is driven
|
||||
* entirely by the platform's document framework, so it has no Dart unit tests.
|
||||
* See MainActivity.viewDirectory for the intents that open it.
|
||||
*/
|
||||
class RecordingsDocumentsProvider : DocumentsProvider() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
|
||||
/** Root id and document id of the exposed folder itself. */
|
||||
const val ROOT_ID = "recordings"
|
||||
|
||||
/**
|
||||
* Remembers the folder Flutter is actually recording into. Written on
|
||||
* every open-folder request so a user-configured path is honoured, and
|
||||
* read back by [rootDirectory] when the platform enumerates roots (which
|
||||
* can happen with no Activity alive).
|
||||
*/
|
||||
private const val PREFS = "pluriwave_recordings_root"
|
||||
private const val KEY_PATH = "path"
|
||||
|
||||
/**
|
||||
* Mirrors path_provider's `getApplicationDocumentsDirectory()` on
|
||||
* Android (`context.getDir("flutter", MODE_PRIVATE)`) plus the
|
||||
* `grabaciones` subfolder appended by
|
||||
* `ServicioGrabacionRadio.directorioEfectivo()`. Used until Flutter has
|
||||
* reported the effective path at least once.
|
||||
*/
|
||||
private fun defaultDirectory(context: Context): File =
|
||||
File(context.getDir("flutter", Context.MODE_PRIVATE), "grabaciones")
|
||||
|
||||
fun authority(context: Context): String = "${context.packageName}.recordings"
|
||||
|
||||
/** `ACTION_VIEW` target that opens the file manager at this root. */
|
||||
fun rootUri(context: Context): Uri =
|
||||
DocumentsContract.buildRootUri(authority(context), ROOT_ID)
|
||||
|
||||
/** `ACTION_VIEW` target for the root folder as a document. */
|
||||
fun rootDocumentUri(context: Context): Uri =
|
||||
DocumentsContract.buildDocumentUri(authority(context), ROOT_ID)
|
||||
|
||||
/** `EXTRA_INITIAL_URI` target for the `ACTION_OPEN_DOCUMENT_TREE` fallback. */
|
||||
fun rootTreeUri(context: Context): Uri =
|
||||
DocumentsContract.buildTreeDocumentUri(authority(context), ROOT_ID)
|
||||
|
||||
/**
|
||||
* Points the published root at [path] and tells the framework to
|
||||
* refresh, so a folder change in Settings is reflected in the file
|
||||
* manager. No-op when the path is unchanged.
|
||||
*/
|
||||
fun rememberRoot(context: Context, path: String) {
|
||||
val app = context.applicationContext
|
||||
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_PATH, null) == path) return
|
||||
prefs.edit().putString(KEY_PATH, path).apply()
|
||||
try {
|
||||
app.contentResolver.notifyChange(
|
||||
DocumentsContract.buildRootsUri(authority(app)),
|
||||
null
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "recordings_provider notifyChange failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory currently published as [ROOT_ID], created if missing. */
|
||||
fun rootDirectory(context: Context): File {
|
||||
val app = context.applicationContext
|
||||
val stored = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PATH, null)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val directory = if (stored != null) File(stored) else defaultDirectory(app)
|
||||
if (!directory.exists()) directory.mkdirs()
|
||||
return directory
|
||||
}
|
||||
|
||||
private val ROOT_COLUMNS = arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_SUMMARY,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_ICON,
|
||||
)
|
||||
|
||||
private val DOCUMENT_COLUMNS = arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_FLAGS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [DocumentsProvider.getContext] is nullable only before `onCreate`.
|
||||
* Not named requireContext: ContentProvider.requireContext() is API 30 and
|
||||
* minSdk is 24.
|
||||
*/
|
||||
private fun resolveContext(): Context =
|
||||
requireNotNull(context) { "provider context unavailable" }
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: ROOT_COLUMNS)
|
||||
val context = resolveContext()
|
||||
// Ensure the folder exists before the file manager lists an empty root.
|
||||
rootDirectory(context)
|
||||
cursor.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||
add(Root.COLUMN_DOCUMENT_ID, ROOT_ID)
|
||||
// The file manager renders title as the primary label and summary
|
||||
// below it, so the brand identifies the source and the localized
|
||||
// folder name says what it holds.
|
||||
add(Root.COLUMN_TITLE, appLabel(context))
|
||||
add(Root.COLUMN_SUMMARY, AlarmNotificationStrings.recordingsRootTitle(context))
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.FLAG_LOCAL_ONLY or
|
||||
Root.FLAG_SUPPORTS_CREATE or
|
||||
Root.FLAG_SUPPORTS_IS_CHILD
|
||||
)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
|
||||
addRow(cursor, resolve(documentId), documentId)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
|
||||
val parent = resolve(parentDocumentId)
|
||||
// Newest recording first: it is the one the user just made.
|
||||
val children = parent.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
|
||||
for (child in children) {
|
||||
addRow(cursor, child, documentIdFor(child))
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
|
||||
documentId != parentDocumentId &&
|
||||
documentId.startsWith(
|
||||
if (parentDocumentId == ROOT_ID) "$ROOT_ID/" else "$parentDocumentId/"
|
||||
)
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
signal: CancellationSignal?,
|
||||
): ParcelFileDescriptor {
|
||||
val file = resolve(documentId)
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
|
||||
}
|
||||
|
||||
override fun createDocument(
|
||||
parentDocumentId: String,
|
||||
mimeType: String,
|
||||
displayName: String,
|
||||
): String {
|
||||
val parent = resolve(parentDocumentId)
|
||||
val target = uniqueChild(parent, displayName)
|
||||
val created =
|
||||
if (Document.MIME_TYPE_DIR == mimeType) target.mkdir() else target.createNewFile()
|
||||
if (!created) {
|
||||
throw FileNotFoundException("could not create $displayName in $parentDocumentId")
|
||||
}
|
||||
notifyParent(parentDocumentId)
|
||||
return documentIdFor(target)
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) {
|
||||
val file = resolve(documentId)
|
||||
if (!file.deleteRecursively()) {
|
||||
throw FileNotFoundException("could not delete $documentId")
|
||||
}
|
||||
notifyParent(parentDocumentIdOf(documentId))
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String {
|
||||
val file = resolve(documentId)
|
||||
val target = File(file.parentFile, displayName)
|
||||
if (target.exists() || !file.renameTo(target)) {
|
||||
throw FileNotFoundException("could not rename $documentId to $displayName")
|
||||
}
|
||||
notifyParent(parentDocumentIdOf(documentId))
|
||||
return documentIdFor(target)
|
||||
}
|
||||
|
||||
override fun getDocumentType(documentId: String): String = mimeTypeOf(resolve(documentId))
|
||||
|
||||
private fun appLabel(context: Context): String =
|
||||
context.applicationInfo.loadLabel(context.packageManager).toString()
|
||||
|
||||
private fun addRow(cursor: MatrixCursor, file: File, documentId: String) {
|
||||
val isDirectory = file.isDirectory
|
||||
var flags =
|
||||
if (isDirectory) Document.FLAG_DIR_SUPPORTS_CREATE else Document.FLAG_SUPPORTS_WRITE
|
||||
flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME
|
||||
cursor.newRow().apply {
|
||||
add(Document.COLUMN_DOCUMENT_ID, documentId)
|
||||
add(
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
if (documentId == ROOT_ID) {
|
||||
AlarmNotificationStrings.recordingsRootTitle(resolveContext())
|
||||
} else {
|
||||
file.name
|
||||
}
|
||||
)
|
||||
add(Document.COLUMN_MIME_TYPE, mimeTypeOf(file))
|
||||
add(Document.COLUMN_SIZE, file.length())
|
||||
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
|
||||
add(Document.COLUMN_FLAGS, flags)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a document id back to a file, refusing anything that escapes the
|
||||
* published root -- a caller-supplied id must never reach a sibling of the
|
||||
* recordings folder via `..` segments.
|
||||
*/
|
||||
private fun resolve(documentId: String): File {
|
||||
val root = rootDirectory(resolveContext())
|
||||
if (documentId == ROOT_ID) return root
|
||||
if (!documentId.startsWith("$ROOT_ID/")) {
|
||||
throw FileNotFoundException("unknown document id $documentId")
|
||||
}
|
||||
val relative = documentId.removePrefix("$ROOT_ID/")
|
||||
val target = File(root, relative).canonicalFile
|
||||
val rootPath = root.canonicalPath
|
||||
if (target.path != rootPath && !target.path.startsWith("$rootPath${File.separator}")) {
|
||||
throw FileNotFoundException("document id escapes the root: $documentId")
|
||||
}
|
||||
if (!target.exists()) throw FileNotFoundException("missing document $documentId")
|
||||
return target
|
||||
}
|
||||
|
||||
private fun documentIdFor(file: File): String {
|
||||
val rootPath = rootDirectory(resolveContext()).canonicalPath
|
||||
val filePath = file.canonicalPath
|
||||
if (filePath == rootPath) return ROOT_ID
|
||||
return "$ROOT_ID/${filePath.removePrefix("$rootPath${File.separator}").replace(File.separatorChar, '/')}"
|
||||
}
|
||||
|
||||
private fun parentDocumentIdOf(documentId: String): String =
|
||||
documentId.substringBeforeLast('/', ROOT_ID).takeIf { it.isNotBlank() } ?: ROOT_ID
|
||||
|
||||
private fun notifyParent(parentDocumentId: String) {
|
||||
try {
|
||||
val ctx = resolveContext()
|
||||
ctx.contentResolver.notifyChange(
|
||||
DocumentsContract.buildChildDocumentsUri(authority(ctx), parentDocumentId),
|
||||
null
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "recordings_provider notifyChange failed parent=$parentDocumentId", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends ` (n)` before the extension until the name is free. */
|
||||
private fun uniqueChild(parent: File, displayName: String): File {
|
||||
var candidate = File(parent, displayName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dot = displayName.lastIndexOf('.')
|
||||
val base = if (dot > 0) displayName.substring(0, dot) else displayName
|
||||
val extension = if (dot > 0) displayName.substring(dot) else ""
|
||||
var index = 1
|
||||
while (candidate.exists()) {
|
||||
candidate = File(parent, "$base ($index)$extension")
|
||||
index++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun mimeTypeOf(file: File): String {
|
||||
if (file.isDirectory) return Document.MIME_TYPE_DIR
|
||||
val extension = file.extension.lowercase()
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
?: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,17 @@
|
||||
<files-path
|
||||
name="files"
|
||||
path="." />
|
||||
<!--
|
||||
path_provider's getApplicationDocumentsDirectory() maps to
|
||||
context.getDir("flutter") -> <data>/app_flutter, a sibling of files/ that
|
||||
no FileProvider tag covers directly. Without this root,
|
||||
getUriForFile() throws for every radio recording and "open last
|
||||
recording" fails. FileProvider canonicalizes roots, so the ../ hop
|
||||
resolves to <data>/app_flutter.
|
||||
-->
|
||||
<files-path
|
||||
name="app_flutter"
|
||||
path="../app_flutter/" />
|
||||
<cache-path
|
||||
name="cache"
|
||||
path="." />
|
||||
|
||||
Reference in New Issue
Block a user