Make the Android 13+ pass, and fix what it found

The phone runs Android 12, so the questions that matter most for a release were exactly the ones it could not answer. Installed an API 35 AVD (sdkmanager was already present and dl.google.com reachable) rather than waiting for a public test track to report back. It found two user-visible defects, neither of which any test could have caught.

On Android 13+ the app asked for no runtime permission at all, so the progress notification was dead. StorageAccessPolicy.missingPermissions returned an empty list from API 33 on the reasoning that "all files access" is a special permission and browsing therefore needs no runtime grant — true as far as browsing goes, but the early return also dropped POST_NOTIFICATIONS, and nothing else in the app requested it. startForeground still ran, so nothing crashed and all 203 tests passed, while dumpsys showed granted=false and importance=NONE. Settings could not fix that either, because Android will not let a user grant a permission the app has never asked for. The notification grant is now requested alongside the storage grant, which is the only moment Android accepts it.

The four READ_MEDIA_* declarations were in the same position, and the manifest comment claiming they back thumbnails and viewers on Android 13+ was wrong. Coil loads both from a real filesystem path — the code comments already say "all-files mode only" — and the one MediaStore use is a best-effort delete request that "all files access" already covers. They were never requested, so they were never granted, so nothing could have depended on them. Removed, which also settles the checklist rule that declaring an unused permission is itself a policy violation.

The selection bar drew its last three actions off the screen on a phone. With one archive selected it holds ten icon buttons, ~480 dp at the 48 dp minimum touch size against the 411 dp a phone has, and BottomAppBar is a plain Row that does not scroll. Compose laid the surplus out past the right edge: measured on a 1080 px screen the row ran Copy(167) to Compress(923-980, itself squeezed to 57 px) and stopped, so Extract here, Select all and Clear selection were unreachable, while the same selection in a 2400 px landscape window showed all ten. The logic was never wrong — ArchiveFormats.isExtractable had judged the .zip correctly — the app just looked like it could not extract. The action row now scrolls, verified by swiping it in portrait and seeing all ten appear.

Verified on the AVD rather than only built: the notification dialog now appears and importance goes NONE -> DEFAULT, a real 220 MB copy posts notification 1001 on channel file-operations with ONGOING_EVENT|FOREGROUND_SERVICE and tears it down plus stops the service when done, edge-to-edge is correct under API 35s enforcement, predictive back returns from a folder and keeps the activity resumed, and Compress to ZIP produced an archive the host unzip read back with the right relative paths and contents. 204 JVM tests, lint clean, release artefacts re-measured.
This commit is contained in:
2026-10-15 11:39:48 +02:00
parent 5786b618b7
commit bb1187881b
8 changed files with 220 additions and 66 deletions
+12 -8
View File
@@ -18,17 +18,16 @@
tools:ignore="ScopedStorage" />
<!--
Media access, used for thumbnails and the built-in viewers on Android 13+.
On Android 12 and below this collapses to READ_EXTERNAL_STORAGE.
Read access on Android 12 and below, where it is the grant that carries media.
From Android 13 this role is taken over by "all files access" below, and the
READ_MEDIA_* permissions are deliberately *not* declared: the app reads through the
filesystem — Coil loads thumbnails and the viewers from a real path — and never
lists or queries MediaStore, so declaring them would mean declaring permissions
that are never requested and never used.
-->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Android 14+ partial ("Selected photos") access. -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<!--
Needed only on Android 10 (API 29) and below to write through the legacy
@@ -45,7 +44,12 @@
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android 13+: shows the operation progress notification (optional, never required). -->
<!--
Android 13+: shows the operation progress notification. Optional — denying it costs
only the progress display, the operation itself still runs — but it is requested
when the storage grant is asked for, because Android will not let the user grant it
in Settings to an app that has never asked.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
@@ -24,6 +24,7 @@ object StorageAccessPolicy {
const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE"
const val WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE"
const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"
/**
* "All files access" is a special app-op from Android 11; on Android 10 and below
@@ -41,13 +42,24 @@ object StorageAccessPolicy {
/**
* The runtime permissions still needed for the current mode.
*
* From Android 13 the media permissions are a separate concern (thumbnails and the
* built-in viewers, M5), and "all files access" itself is a special permission
* rather than a runtime one — so browsing needs nothing extra there.
* From Android 13 browsing needs nothing: "all files access" is a special app-op
* rather than a runtime grant, and reads go through the filesystem — Coil loads
* thumbnails and the viewers from a real path, not from `MediaStore` — so the
* `READ_MEDIA_*` permissions are declared for nothing and are not requested here.
*
* The notification grant is a different concern. It does not affect browsing, but
* from Android 13 the system drops the operation progress notification without it,
* and — unlike an app-op — it cannot be granted anywhere in Settings unless the app
* has asked at least once. So it is asked for alongside the storage grant, which is
* the moment the user commits to the app's full mode.
*/
fun missingPermissions(sdkInt: Int, isGranted: (String) -> Boolean): List<String> {
val missing = ArrayList<String>()
if (sdkInt >= API_TIRAMISU) return missing
if (sdkInt >= API_TIRAMISU) {
if (!isGranted(POST_NOTIFICATIONS)) missing += POST_NOTIFICATIONS
return missing
}
if (!isGranted(READ_EXTERNAL_STORAGE)) missing += READ_EXTERNAL_STORAGE
if (sdkInt < API_R && !isGranted(WRITE_EXTERNAL_STORAGE)) missing += WRITE_EXTERNAL_STORAGE
@@ -1,10 +1,13 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Close
@@ -90,40 +93,57 @@ fun SelectionBar(
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(start = 8.dp),
)
Spacer(Modifier.weight(1f))
IconButton(onClick = onCopy) {
Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.browser_action_copy))
}
IconButton(onClick = onCut) {
Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut))
}
if (state.selectedEntries.size == 1) {
IconButton(onClick = onRename) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
/*
* The actions must not depend on the window being wide enough to hold all of them.
* With a single archive selected there are ten buttons, which at the 48 dp minimum
* touch size need ~480 dp — more than a 411 dp phone has. In a plain row Compose lays
* the surplus out past the right edge, where nothing can reach it: measured on a
* 1080 px (411 dp) screen, "Extract", "Select all" and "Clear selection" were drawn
* off-window and were unreachable, while the same selection in a 2400 px landscape
* window showed all ten. Scrolling the row keeps every action reachable at any width
* and at any font scale.
*/
Row(
modifier = Modifier
.weight(1f, fill = false)
.horizontalScroll(rememberScrollState())
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onCopy) {
Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.browser_action_copy))
}
IconButton(onClick = onDetails) {
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details))
IconButton(onClick = onCut) {
Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut))
}
IconButton(onClick = onShare) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.browser_action_share))
if (state.selectedEntries.size == 1) {
IconButton(onClick = onRename) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
}
IconButton(onClick = onDetails) {
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details))
}
IconButton(onClick = onShare) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.browser_action_share))
}
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.browser_action_delete))
}
IconButton(onClick = onCompress) {
Icon(Icons.Filled.Archive, contentDescription = stringResource(R.string.browser_action_compress))
}
if (state.selectedEntries.singleOrNull()?.let { ArchiveFormats.isExtractable(it.name) } == true) {
IconButton(onClick = onExtract) {
Icon(Icons.Filled.Unarchive, contentDescription = stringResource(R.string.browser_action_extract))
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.browser_action_delete))
}
IconButton(onClick = onCompress) {
Icon(Icons.Filled.Archive, contentDescription = stringResource(R.string.browser_action_compress))
}
if (state.selectedEntries.singleOrNull()?.let { ArchiveFormats.isExtractable(it.name) } == true) {
IconButton(onClick = onExtract) {
Icon(Icons.Filled.Unarchive, contentDescription = stringResource(R.string.browser_action_extract))
}
}
IconButton(onClick = onSelectAll) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.browser_action_select_all))
}
IconButton(onClick = onClear) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.browser_action_clear_selection))
}
}
IconButton(onClick = onSelectAll) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.browser_action_select_all))
}
IconButton(onClick = onClear) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.browser_action_clear_selection))
}
}
}
@@ -23,10 +23,27 @@ class StorageAccessPolicyTest {
}
@Test
fun `runtime permissions shrink as the platform moves on`() {
assertTrue(StorageAccessPolicy.missingPermissions(33) { false }.isEmpty())
assertTrue(StorageAccessPolicy.missingPermissions(36) { false }.isEmpty())
fun `from android 13 notifications are the only grant left to ask for`() {
assertEquals(
listOf(StorageAccessPolicy.POST_NOTIFICATIONS),
StorageAccessPolicy.missingPermissions(33) { false },
)
assertEquals(
listOf(StorageAccessPolicy.POST_NOTIFICATIONS),
StorageAccessPolicy.missingPermissions(36) { false },
)
// Browsing needs nothing from Android 13 — "all files access" covers it, and the
// READ_MEDIA_* permissions are no longer declared, let alone requested.
assertTrue(
StorageAccessPolicy
.missingPermissions(36) { it == StorageAccessPolicy.POST_NOTIFICATIONS }
.isEmpty(),
)
}
@Test
fun `storage grants are asked for up to android 12`() {
assertEquals(
listOf(StorageAccessPolicy.READ_EXTERNAL_STORAGE),
StorageAccessPolicy.missingPermissions(30) { false },
@@ -53,6 +70,7 @@ class StorageAccessPolicyTest {
fun `the permission constants match the platform strings`() {
assertEquals("android.permission.READ_EXTERNAL_STORAGE", StorageAccessPolicy.READ_EXTERNAL_STORAGE)
assertEquals("android.permission.WRITE_EXTERNAL_STORAGE", StorageAccessPolicy.WRITE_EXTERNAL_STORAGE)
assertEquals("android.permission.POST_NOTIFICATIONS", StorageAccessPolicy.POST_NOTIFICATIONS)
assertFalse(StorageAccessPolicy.API_R < 30)
}
}