fix(android): let the app reach the user's music

Three of plan 016's four blockers. Each is a different reason the app
could not work at all on a phone.

**It had no permission to read anything.** The generated manifest asked
for INTERNET, VIBRATE, biometrics, location and a camera, and nothing
whatever about storage -- so at targetSdk 35 the app could see its own
private directory and no music. It now declares READ_MEDIA_AUDIO, the
two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE.

That last one is deliberate and is the load-bearing choice. This app is
a library manager: audio_files.file_path is the primary key of
ownership, the scanner walks a directory the user chose, and tagwriter
rewrites files in place. MediaStore offers no stable directory to walk
and no in-place write, so scoped storage is not "more work" here, it is
a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted,
which is acceptable only because this ships as an APK through the
package registry -- if it ever targets Play, that line is what has to
go, and plan 016 says what replaces it.

It is granted on a Settings screen rather than in a dialog, so it
cannot be requested with requestPermissions(). MainActivity opens that
screen on every cold start until access exists -- there is no degraded
mode worth offering -- and re-checks in onResume, because the way back
from another task is a resume, emitting android:storageAccess so the
frontend can react.

**The first-run flow could not complete.** All three call sites asked
for a folder through the Wails dialog, which returns an error on
Android: SAF yields tree URIs and this app is keyed on paths. So the
app browses the filesystem itself, which it can now do. ListDirectories
lists directories only (the thing being chosen is a library root),
skips what it cannot stat rather than failing the listing (Android's
storage root holds directories no app may enter), follows symlinks
(os.DirEntry reports the link, so a symlinked music folder would
silently vanish), and hides dotted entries.

utils/pick-directory.ts is the one place that chooses between the two,
so the three call sites changed by one line each. **Which platform is
asked of the backend**, not of System.IsAndroid(): the dialog is
backend code, so the backend is what knows whether it can open one; it
answers for iOS at the same time; and it keeps the fallback testable
through the ordinary transport fake rather than a module mock of the
Wails runtime, whose platform helpers read build constants.

**And MPRIS was compiled into the Android build**, because android
implies the linux build tag, so it went looking for a session bus that
does not exist. mpris_linux.go is `linux && !android` now and the stub
covers Android, which means no lock-screen transport there yet -- a
missing feature rather than a broken one, and the remaining blocker.

The foreground service is typed mediaPlayback rather than the
scaffold's dataSync, with the matching permission, so playback can
survive the screen locking once there is a MediaSession to drive it.
The type in the manifest and the one passed to startForeground must
agree or startForeground throws.
This commit is contained in:
2026-08-16 17:18:03 -04:00
parent 78576b8da9
commit e14a34fccf
16 changed files with 1227 additions and 10 deletions
+49 -1
View File
@@ -15,6 +15,48 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!--
Playback has to survive the screen locking, and that needs a
foreground service typed mediaPlayback rather than dataSync. The
type in the <service> element and the permission here must agree,
or startForeground throws at runtime.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Reading the user's music.
READ_MEDIA_AUDIO is the Android 13+ grant and READ_EXTERNAL_STORAGE
is its predecessor, capped so it is not requested where it no
longer applies. Both give access through **MediaStore**.
MANAGE_EXTERNAL_STORAGE is what gives access through the
*filesystem*, and this app needs it rather than merely preferring
it: `audio_files.file_path` is the primary key of ownership, the
scanner walks a directory the user chose, and every
GetFilePathsBy... query exists to hand a path to the player.
MediaStore offers no stable directory to walk and no way to write
a tag back in place, so the alternative is not "more work" but a
different application.
It is a Play-restricted permission, granted on a Settings screen
rather than in a dialog. That is acceptable *here* only because
this app is distributed as an APK through the package registry and
not through Play — see docs/android-release.md. If it ever targets
Play, this is the line that has to go, and plan 016 says what
would replace it.
-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
@@ -54,10 +96,16 @@
android:resource="@xml/file_paths" />
</provider>
<!--
mediaPlayback, not the scaffold's dataSync: this app's reason
for staying alive in the background is that a song is
playing, and Android matches the declared type against what
the service actually does.
-->
<service
android:name=".WailsForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -11,10 +11,13 @@ import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.Uri;
import android.Manifest;
import android.os.BatteryManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.PowerManager;
import android.provider.Settings;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@@ -89,6 +92,10 @@ public class MainActivity extends AppCompatActivity {
bridge = new WailsBridge(this);
bridge.initialize();
// Ask for access to the user's music before the frontend has
// anything to say about it. See ensureStorageAccess().
ensureStorageAccess();
// Set up WebView
setupWebView();
@@ -96,6 +103,78 @@ public class MainActivity extends AppCompatActivity {
loadApplication();
}
/**
* Obtain access to the user's music.
*
* <p>This app is a library manager: its database is keyed on file
* paths, its scanner walks a directory the user chose, and its tag
* writer rewrites files in place. MediaStore offers none of those,
* so the app holds MANAGE_EXTERNAL_STORAGE — which is granted on a
* Settings screen rather than in a dialog, and therefore cannot be
* requested with requestPermissions().
*
* <p>The screen is opened on every cold start until access exists,
* because without it the app can see nothing at all and there is no
* degraded mode worth offering. Returning from it lands in
* onResume, which re-checks and tells the frontend.
*
* <p>Below Android 11 there is no all-files concept and plain
* READ_EXTERNAL_STORAGE is both sufficient and a normal dialog.
*/
private void ensureStorageAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) {
return;
}
// The per-app screen is the one that can actually grant it.
// A few OEM builds do not implement it, so fall back to the
// global list rather than leaving the user with nothing.
try {
startActivity(new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + getPackageName())));
} catch (Exception e) {
Log.w(TAG, "per-app all-files screen unavailable: " + e.getMessage());
try {
startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION));
} catch (Exception e2) {
Log.w(TAG, "no all-files settings screen at all: " + e2.getMessage());
}
}
return;
}
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1010);
}
}
/**
* Whether the app can currently read the user's music, by the same
* test the Go side uses.
*/
private boolean hasStorageAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
}
/**
* Tell the frontend whether music is readable. Emitted on resume
* rather than only at startup, because the grant happens on a
* Settings screen in another task and the way back is a resume.
*/
private void emitStorageAccess() {
if (bridge == null) {
return;
}
bridge.emitEvent("android:storageAccess",
"{\"granted\":" + (hasStorageAccess() ? "true" : "false") + "}");
}
@SuppressLint("SetJavaScriptEnabled")
private void setupWebView() {
webView = findViewById(R.id.webview);
@@ -768,6 +847,9 @@ public class MainActivity extends AppCompatActivity {
if (bridge != null) {
bridge.onResume();
}
// The all-files grant happens on a Settings screen in another
// task, so a resume is how the app finds out it was given.
emitStorageAccess();
}
@Override
@@ -58,7 +58,12 @@ public class WailsForegroundService extends android.app.Service {
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
// MEDIA_PLAYBACK, not the scaffold's DATA_SYNC. It must match
// android:foregroundServiceType in the manifest, or
// startForeground throws; and on Android 14+ the declared type
// is what decides whether the service may start from the
// background at all.
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(NOTIFICATION_ID, n);
}