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:
@@ -12,6 +12,40 @@ upload needs a **higher `versionCode`** than every previous upload.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The progress notification never appeared on Android 13 or newer, and five permissions were
|
||||
declared that the app never used.** `StorageAccessPolicy.missingPermissions` returned an empty
|
||||
list from API 33 on the reasoning that "all files access" is a special permission rather than
|
||||
a runtime one, so browsing needs no runtime grant. That part is true — but the early return
|
||||
also silently dropped `POST_NOTIFICATIONS` from the set, and nothing else in the app ever
|
||||
requested it. On Android 13+ the grant could therefore never be given at all: `startForeground`
|
||||
still ran, so nothing crashed and every test passed, while the system dropped the notification
|
||||
(`importance=NONE`) and even Settings could not turn it on, because Android will not let a user
|
||||
grant a permission the app has never asked for. The four `READ_MEDIA_*` declarations were in
|
||||
the same position — declared for "thumbnails and the built-in viewers on Android 13+", never
|
||||
requested, and never needed: Coil loads thumbnails and the viewers from a real filesystem path,
|
||||
and the only `MediaStore` use is the best-effort delete request, which "all files access" already
|
||||
covers. Those declarations are removed, and the notification grant is now requested alongside
|
||||
the storage grant — the moment the user commits to the app's full mode, and the only moment
|
||||
Android accepts. Verified on an Android 15 emulator: the dialog now appears, the app's
|
||||
notification importance goes `NONE → DEFAULT`, and the progress notification is posted, kept up
|
||||
to date and torn down around a real 220 MB copy.
|
||||
|
||||
- **The selection bar drew its last three actions off the screen on a phone, where nothing could
|
||||
reach them.** With a single archive selected the bar holds ten `IconButton`s — Copy, Cut, Rename,
|
||||
Details, Share, Delete, Compress, Extract, Select all and Clear selection — and at the 48 dp
|
||||
minimum touch size that needs roughly 480 dp plus the item-count label, against the 411 dp a
|
||||
normal phone has. `BottomAppBar` is a plain `Row` and does not scroll, so Compose laid the
|
||||
surplus out past the right edge and clipped it: measured on a 1080 px (411 dp) screen the row
|
||||
ran Copy(167)…Compress(923–980, itself squeezed from 63 px to 57) and then simply stopped, while
|
||||
the same selection in a 2400 px landscape window showed all ten. `Extract here`, `Select all`
|
||||
and `Clear selection` were therefore unreachable on the form factor most people use, which made
|
||||
the app's own archive feature look like it did not exist — and the extract path itself was fine,
|
||||
`ArchiveFormats.isExtractable` had judged the `.zip` correctly all along. The action row now
|
||||
scrolls, so nothing can be clipped at any width or font scale. Found by actually driving compress
|
||||
and extract from the UI on the API 35 emulator instead of trusting the scripted long-press
|
||||
gesture that had already proved flaky; the note that this path "is still worth a manual pass"
|
||||
turned out to be exactly right.
|
||||
|
||||
- **`.xz` extraction would have crashed on a real device.** Commons Compress declares its
|
||||
LZMA/LZMA2 support — the `.xz` compressor and the 7z format — against `org.tukaani:xz` as
|
||||
an **optional** dependency. Optional means it is not pulled in transitively, so nothing
|
||||
|
||||
+2
-3
@@ -33,10 +33,9 @@ is handled entirely by Google Play, never by the app (see [Purchases](#purchases
|
||||
| Permission | Purpose |
|
||||
|---|---|
|
||||
| `MANAGE_EXTERNAL_STORAGE` ("All files access") | Required for the app's core purpose: browsing and managing files anywhere on the device. Google Play only allows this permission for apps whose core purpose needs it — file managers are one of the accepted cases — and the app must be granted it by you in Android Settings. If you decline, the app still works, but only in a folder you pick. |
|
||||
| Read media (`READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` / `READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE` on older Android) | To show thumbnails and to open images, audio and video. |
|
||||
| `READ_MEDIA_VISUAL_USER_SELECTED` | Supports Android 14's "selected photos only" partial access. |
|
||||
| `READ_EXTERNAL_STORAGE` (Android 12 and below) | Read access on older Android versions, where this single grant is what carries file and media reads. From Android 13 that role is taken over by "All files access" above, so the app declares **no `READ_MEDIA_*` permission at all**: it reads through the filesystem (thumbnails and viewers load from a real path) and never queries the media store. |
|
||||
| `WRITE_EXTERNAL_STORAGE` (Android 10 only) | Used only to write files you ask for on that older Android version. |
|
||||
| Notifications (`POST_NOTIFICATIONS`) | Optional. Shows progress while a long copy, move, delete or extraction is running. Denying it changes nothing except that the progress bar is not shown. |
|
||||
| Notifications (`POST_NOTIFICATIONS`, Android 13+) | Optional. Shows progress while a long copy, move, delete or extraction is running. Denying it changes nothing except that the progress bar is not shown. The app asks for it once, together with the file-access request — Android will not let you grant it in Settings to an app that has never asked. |
|
||||
| Foreground service (`FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC`) | Keeps a long file operation running while the app is in the background, so a move is not cut in half. |
|
||||
|
||||
## Deleting files
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ this file honest: an untested claim is a bug waiting to be reported.
|
||||
|---|---|---|
|
||||
| Xiaomi **M2101K7AG** (Redmi, `sunny`), MIUI V140 | 12 (API 31), arm64-v8a | A real phone with a real 55 GB library. Auto-rotate was on, so runs happened in both orientations. |
|
||||
| AOSP `phone` AVD | 12 (API 31), x86_64 | Software emulation — the host exposes no `/dev/kvm`. Used from M0–M6. |
|
||||
| AOSP `phone35` AVD | **15 (API 35)**, x86_64 | Software emulation, same reason. Added for the Android 13+ surface the phone cannot reach: the permission model, mandatory edge-to-edge, predictive back. |
|
||||
|
||||
Connecting a phone to this dev container needs one fix: the USB node is created
|
||||
`root:plugdev 0660`, and the container's user is not in `plugdev`, so adb cannot open it.
|
||||
@@ -48,18 +49,77 @@ An udev rule widening the mode for Android vendors fixes it
|
||||
The UI never froze — the progress bar kept animating throughout, which confirms the I/O
|
||||
stays off the main thread — but "slow and silent" is still a defect.
|
||||
|
||||
## The Android 15 (API 35) session
|
||||
|
||||
The phone is on Android 12, and the checks that matter most for a release are precisely the
|
||||
ones Android 12 cannot answer. An API 35 AVD was installed for that reason
|
||||
(`sdkmanager --install 'system-images;android-35;default;x86_64'`, AVD on the `pixel_6`
|
||||
profile, 1080×2400 @ 420dpi — the phone's geometry), rather than waiting for a public test
|
||||
track to report back.
|
||||
|
||||
| What | Result |
|
||||
|---|---|
|
||||
| Cold start and browse | Fine. Storage root, then "Internal storage / Download", with the real file and size shown. |
|
||||
| Edge-to-edge, which API 35 enforces | **Correct.** The status and navigation bars draw over the app background and the app bar sits *below* the status bar, so the `Scaffold` insets are being applied. This was the single most likely Android 15 regression. |
|
||||
| Predictive back | **Correct.** `BACK` from inside a folder returns to the root and the activity stays resumed, so the `BackHandler` composed after the overlay branch works under the `OnBackInvoked` dispatcher too. |
|
||||
| Permission request | **Broke, fixed.** On the shipped build Android 13+ was asked for nothing at all; see below. |
|
||||
| The progress notification | **Fixed, then verified.** After the fix, a real 220 MB copy posts notification `1001` on channel `file-operations` with `ONGOING_EVENT\|NO_CLEAR\|FOREGROUND_SERVICE` and `category=progress`, then removes it and stops the service when the copy finishes. Before the fix the app's notification importance was `NONE`. |
|
||||
| Copy to completion | The staged copy landed at `/sdcard/bigfile.bin`, byte-count identical, via the overflow menu's "Paste here". |
|
||||
| Compress and extract from the UI | **Found a bug, fixed.** Long-pressing a folder and choosing "Compress to ZIP" produced a `tocompress.zip` that the host's `unzip` read back with the right relative paths and contents. Selecting that `.zip` then exposed the selection bar clipping its own actions — see below. |
|
||||
| Long-press from adb | `input swipe x y x y 700` on a single point *did* open the selection bar and reach Copy. The same gesture has been flaky on the MIUI phone, so it still deserves a manual pass. |
|
||||
|
||||
### The bugs this session found
|
||||
|
||||
**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 —
|
||||
deliberately, with a test asserting it — 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 requested it: the manifest
|
||||
declared it and the service posted it, so it looked implemented, while `dumpsys` showed
|
||||
`granted=false` and `importance=NONE`. No crash and all 203 tests green — the feature was
|
||||
simply invisible, and unrecoverable, because Android will not let a user grant in Settings a
|
||||
permission the app has never asked for. The four `READ_MEDIA_*` declarations were in the same
|
||||
position, and the manifest comment claiming they backed thumbnails on Android 13+ was wrong:
|
||||
Coil loads thumbnails and the viewers from a real filesystem path. Fixed by removing those
|
||||
declarations and requesting the notification grant alongside the storage grant; a re-run on the
|
||||
same AVD shows the dialog, `importance` `NONE → DEFAULT`, and the notification lifecycle above.
|
||||
Details in [CHANGELOG](../CHANGELOG.md).
|
||||
|
||||
**The selection bar drew three of its own actions off the screen.** With a single archive selected
|
||||
the bar holds ten icon buttons, which at the 48 dp minimum touch size need ~480 dp against the
|
||||
411 dp this window has. `BottomAppBar` is a plain `Row` and does not scroll, so the surplus was
|
||||
laid out past the right edge and clipped: the row measured Copy at x=167 through Compress at
|
||||
923–980 — Compress already squeezed from 63 px to 57 — and then simply stopped, leaving "Extract
|
||||
here", "Select all" and "Clear selection" unreachable. The same selection in a 2400 px landscape
|
||||
window showed all ten, which isolates it to width: nothing was wrong with the logic,
|
||||
`ArchiveFormats.isExtractable` had judged the `.zip` correctly all along, and the app's own
|
||||
archive feature merely looked absent on the shape of phone most people hold. Wrapping the actions
|
||||
in a scrollable row makes every one of them reachable again, verified by swiping the bar in
|
||||
portrait and seeing all ten appear. The row still *scrolls* on a 411 dp phone rather than showing
|
||||
everything at once, which is honest but not ideal — moving the two least-used actions (Select all,
|
||||
Clear selection) into the overflow menu would be the better fix, and is left as deliberate
|
||||
follow-up rather than bundled in here.
|
||||
|
||||
The lesson is the one the phone already taught twice: a green test suite says the code does
|
||||
what the tests describe, not that the app does anything on a device. Both times the gap was a
|
||||
user-visible promise the code no longer kept — once in a dialog, once in a permission.
|
||||
|
||||
## Not verified, and why
|
||||
|
||||
- **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900`
|
||||
*does* open the selection action bar on this MIUI build — it was driven that way to reach
|
||||
Delete, Rename and Compress — but the same gesture has also landed as a plain tap, which
|
||||
opens the row instead of selecting it. So the bar is reachable from the host, and yet
|
||||
compress/extract from the UI is still worth a manual pass rather than trusted to a scripted
|
||||
gesture. The archive engine itself is covered by end-to-end JVM tests, including a zip-slip
|
||||
archive.
|
||||
opens the row instead of selecting it. Driving it manually on the API 35 AVD then paid for the
|
||||
caution: the compress/extract pass below found the selection bar clipping its own actions. The
|
||||
archive engine itself is covered by end-to-end JVM tests, including a zip-slip archive.
|
||||
- **The SAF folder-picker round trip.** Driving the system picker and granting a tree was
|
||||
not attempted; the code path is unit-tested against a stand-in `DocumentsProvider`.
|
||||
- **Image viewer and thumbnails.** Verified on the AVD in M4/M5; not repeated on the phone.
|
||||
- **The foreground-service notification** during a long copy. Started in M3 and unit-tested;
|
||||
not observed on hardware.
|
||||
- **Archive creation/extraction against real files.** Only exercised in-memory.
|
||||
- **The foreground-service notification** during a long copy. **Now observed** on the API 35 AVD
|
||||
(see above), including its teardown — so what is left unverified is a *physical* device, where
|
||||
the system may defer or kill a background `dataSync` service on its own schedule.
|
||||
- **Archive creation/extraction against real files.** Now driven end-to-end on the API 35 AVD: a
|
||||
folder of three files compressed to a `.zip` that the host's `unzip` read back with the right
|
||||
relative paths and contents, and that `.zip` then selected for extraction. What is still
|
||||
untested is the exotic end of the format list — 7z, `.xz`, the tar wrappers — against real
|
||||
files rather than the fixtures the JVM tests build.
|
||||
|
||||
@@ -42,16 +42,20 @@ rest are things you must do in the Play Console or with your own accounts.
|
||||
|
||||
- [x] **[code] re-verified 2026-10-15.** `bundleRelease` and `assembleRelease` both succeed
|
||||
with R8 minification and resource shrinking on.
|
||||
- `app-release.aab` is **4 836 490 bytes**
|
||||
(`sha256 0c0702d3917e9b94f42dabfce8002cae6b9377ee4b25d054bf97f73b4c3a8652`).
|
||||
- `app-release.apk` is **2 155 563 bytes**, signed by the upload key above.
|
||||
- `app-release.aab` is **4 837 465 bytes**
|
||||
(`sha256 9c2b3a007784297511f29b107a3be40e648709d7702315c53b93ef18f5979070`).
|
||||
- `app-release.apk` is **2 155 455 bytes**, signed by the upload key above.
|
||||
- Both report `versionCode=1 versionName=1.0.0 targetSdk=36` and exactly the
|
||||
permission set in step 4 — **no `INTERNET`**, so "nothing leaves the device" holds
|
||||
in the shipped artefact and not just in the source manifest.
|
||||
- The figures moved from the first measurement (4 682 814 / 2 081 955) because of the
|
||||
LZMA library behind `.xz` and 7z. The permission set did **not** change: the share
|
||||
target, the file picker and the trash index all arrived without a single new
|
||||
permission, which is the point of having designed them that way.
|
||||
- The figures barely moved again, but the permission set did: the four `READ_MEDIA_*`
|
||||
declarations are gone, removed once the Android 15 pass showed the app never
|
||||
requested them and never read through `MediaStore` (see below). Four manifest lines
|
||||
are worth only a few hundred bytes, hence 4 837 465 rather than a visible drop; the
|
||||
first measurement was 4 682 814 / 2 081 955, before the LZMA library behind `.xz`
|
||||
and 7z. The permission set has only ever shrunk: the share target, the file picker,
|
||||
the trash index and the archive work all arrived without a single new permission,
|
||||
which is the point of having designed them that way.
|
||||
- ⚠️ **Re-measure after any further change.** These numbers are a record of a verified
|
||||
artefact, not a constant — a stale hash here is worse than no hash, because it looks
|
||||
checked.
|
||||
@@ -99,10 +103,6 @@ Expected, and nothing else:
|
||||
```
|
||||
MANAGE_EXTERNAL_STORAGE
|
||||
READ_EXTERNAL_STORAGE (maxSdkVersion=32)
|
||||
READ_MEDIA_IMAGES
|
||||
READ_MEDIA_VIDEO
|
||||
READ_MEDIA_AUDIO
|
||||
READ_MEDIA_VISUAL_USER_SELECTED
|
||||
WRITE_EXTERNAL_STORAGE (maxSdkVersion=29)
|
||||
FOREGROUND_SERVICE
|
||||
FOREGROUND_SERVICE_DATA_SYNC
|
||||
@@ -110,6 +110,13 @@ POST_NOTIFICATIONS
|
||||
<applicationId>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION (added by AndroidX)
|
||||
```
|
||||
|
||||
No `READ_MEDIA_*` appears, and that is deliberate. Browsing and every read go through the
|
||||
filesystem (Coil loads thumbnails and the viewers from a real path); the only `MediaStore`
|
||||
use is the best-effort delete request, which "All files access" already covers. The app
|
||||
never requested the media permissions either, so declaring them would mean declaring
|
||||
permissions that are never used — which is exactly what section 4's "keep the permission
|
||||
set honest" rule forbids.
|
||||
|
||||
## 5. Data safety, content rating, audience
|
||||
|
||||
- [ ] **Data safety** form: the app collects **nothing** and shares **nothing**. Answer "No"
|
||||
|
||||
Reference in New Issue
Block a user