diff --git a/backend/frontendutil/browse.go b/backend/frontendutil/browse.go
new file mode 100644
index 0000000..062598f
--- /dev/null
+++ b/backend/frontendutil/browse.go
@@ -0,0 +1,181 @@
+package frontendutil
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+)
+
+// errNotADirectory is returned when a caller asks to list something
+// that exists but is not a directory.
+var errNotADirectory = errors.New("not a directory")
+
+// DirEntry is one selectable directory in a listing.
+type DirEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+// DirListing is one level of the filesystem, as a folder picker needs
+// it: where we are, what is above, and the directories below.
+//
+// Parent is empty at a root, which is what tells the UI not to draw an
+// "up" affordance rather than having it compute that from the path
+// separator.
+type DirListing struct {
+ Path string `json:"path"`
+ Parent string `json:"parent"`
+ Entries []DirEntry `json:"entries"`
+}
+
+// ListDirectories returns the directories directly inside path, so the
+// frontend can draw a folder picker.
+//
+// **It exists because Android has no directory picker.** Wails' file
+// dialog can choose directories on every desktop platform, and on
+// Android it returns an error: the Storage Access Framework yields tree
+// URIs rather than filesystem paths, and a path is what this app's
+// entire library model is keyed on. Rather than teach the backend about
+// tree URIs, the app browses the filesystem itself — which it can do
+// because it holds all-files access (see the manifest).
+//
+// Three rules, each of which a picker gets wrong if it is not stated:
+// only directories are returned, because the caller is choosing a
+// library root and files are noise; unreadable children are skipped
+// rather than failing the whole listing, since Android's storage root
+// contains directories no app may enter; and hidden directories are
+// omitted, because a music library is not in one and `.thumbnails`
+// alone would swamp the list.
+func (fe *FrontendUtil) ListDirectories(path string) (DirListing, error) {
+ if path == "" {
+ path = fe.DefaultBrowseRoot()
+ }
+
+ path = filepath.Clean(path)
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return DirListing{}, fmt.Errorf("could not open %s: %w", path, err)
+ }
+
+ if !info.IsDir() {
+ return DirListing{}, fmt.Errorf("%w: %s", errNotADirectory, path)
+ }
+
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ return DirListing{}, fmt.Errorf("could not read %s: %w", path, err)
+ }
+
+ dirs := make([]DirEntry, 0, len(entries))
+
+ for _, e := range entries {
+ name := e.Name()
+ if name == "" || name[0] == '.' {
+ continue
+ }
+
+ // A symlink reports itself, not its target, so ask the
+ // filesystem: a symlinked music directory is ordinary and
+ // skipping it would be a bug the user cannot explain.
+ child := filepath.Join(path, name)
+
+ info, err := os.Stat(child)
+ if err != nil || !info.IsDir() {
+ continue
+ }
+
+ dirs = append(dirs, DirEntry{Name: name, Path: child})
+ }
+
+ sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
+
+ parent := filepath.Dir(path)
+ if parent == path {
+ parent = ""
+ }
+
+ return DirListing{Path: path, Parent: parent, Entries: dirs}, nil
+}
+
+// androidSharedStorage is where a user's music lives on Android. It is
+// not derivable from the environment the way a desktop home directory
+// is: HOME inside an Android app process is "/", so os.UserHomeDir()
+// would start the picker at the filesystem root with nothing readable
+// under it.
+const androidSharedStorage = "/storage/emulated/0"
+
+// DefaultBrowseRoot is where a folder picker should open.
+func (fe *FrontendUtil) DefaultBrowseRoot() string {
+ if runtime.GOOS == "android" {
+ for _, candidate := range []string{androidSharedStorage, "/storage"} {
+ if info, err := os.Stat(candidate); err == nil && info.IsDir() {
+ return candidate
+ }
+ }
+
+ return "/"
+ }
+
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
+ return home
+ }
+
+ return string(filepath.Separator)
+}
+
+// StorageAccess reports whether the app can actually read the place the
+// user's music lives.
+type StorageAccess struct {
+ Root string `json:"root"`
+ Readable bool `json:"readable"`
+ Reason string `json:"reason"`
+}
+
+// CheckStorageAccess asks the filesystem rather than the permission
+// system.
+//
+// On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
+// grants on a Settings screen rather than in a dialog — so it can be
+// refused, revoked later, or simply never answered, and the permission
+// API is one more thing that can disagree with reality. Reading the
+// directory is the question the library scanner will actually ask, so
+// it is the one worth answering.
+//
+// It is deliberately not an error return: "we cannot read your music
+// yet" is a state the UI renders, not a failure of the call.
+func (fe *FrontendUtil) CheckStorageAccess() StorageAccess {
+ root := fe.DefaultBrowseRoot()
+
+ if _, err := os.ReadDir(root); err != nil {
+ return StorageAccess{
+ Root: root,
+ Readable: false,
+ Reason: err.Error(),
+ }
+ }
+
+ return StorageAccess{Root: root, Readable: true}
+}
+
+// HasNativeDirectoryPicker reports whether this platform can open a
+// directory dialog at all.
+//
+// It is asked of the backend rather than tested in the frontend with
+// `System.IsAndroid()`, for three reasons. The dialog *is* backend code
+// — `DirectoryPicker` above — so this is the same package saying what
+// it can do. It answers for iOS too without the frontend enumerating
+// platforms. And it makes the frontend's fallback testable through the
+// ordinary transport fake instead of a module mock of the Wails
+// runtime, whose platform helpers read build constants.
+func (fe *FrontendUtil) HasNativeDirectoryPicker() bool {
+ switch runtime.GOOS {
+ case "android", "ios":
+ return false
+ default:
+ return true
+ }
+}
diff --git a/backend/frontendutil/browse_test.go b/backend/frontendutil/browse_test.go
new file mode 100644
index 0000000..dce1993
--- /dev/null
+++ b/backend/frontendutil/browse_test.go
@@ -0,0 +1,207 @@
+package frontendutil
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+// listing helper: a tree with the three shapes the picker has to get
+// right — an ordinary directory, a file (never listed), and a hidden
+// directory (never listed).
+func browseFixture(t *testing.T) string {
+ t.Helper()
+
+ root := t.TempDir()
+
+ for _, dir := range []string{"Music", "Podcasts", "aaa", ".thumbnails"} {
+ if err := os.Mkdir(filepath.Join(root, dir), 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", dir, err)
+ }
+ }
+
+ if err := os.WriteFile(filepath.Join(root, "track.mp3"), []byte("x"), 0o600); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+
+ return root
+}
+
+func TestListDirectories(t *testing.T) {
+ fe := &FrontendUtil{}
+ root := browseFixture(t)
+
+ got, err := fe.ListDirectories(root)
+ if err != nil {
+ t.Fatalf("ListDirectories: %v", err)
+ }
+
+ // Sorted, directories only, no file and no dotted entry.
+ want := []string{"Music", "Podcasts", "aaa"}
+ if len(got.Entries) != len(want) {
+ t.Fatalf("got %d entries %v, want %v", len(got.Entries), got.Entries, want)
+ }
+
+ for i, w := range want {
+ if got.Entries[i].Name != w {
+ t.Errorf("entry %d = %q, want %q", i, got.Entries[i].Name, w)
+ }
+
+ if got.Entries[i].Path != filepath.Join(root, w) {
+ t.Errorf("entry %d path = %q, want %q", i, got.Entries[i].Path, filepath.Join(root, w))
+ }
+ }
+
+ if got.Path != root {
+ t.Errorf("Path = %q, want %q", got.Path, root)
+ }
+
+ if got.Parent != filepath.Dir(root) {
+ t.Errorf("Parent = %q, want %q", got.Parent, filepath.Dir(root))
+ }
+}
+
+// A symlink reports itself rather than its target, so a listing that
+// trusts DirEntry.IsDir() silently drops a symlinked music folder --
+// which is an ordinary thing to have and an unexplainable thing to
+// lose.
+func TestListDirectoriesFollowsSymlinks(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks need elevation on Windows")
+ }
+
+ root := t.TempDir()
+ target := filepath.Join(root, "real")
+
+ if err := os.Mkdir(target, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ link := filepath.Join(root, "linked")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatalf("symlink: %v", err)
+ }
+
+ fe := &FrontendUtil{}
+
+ got, err := fe.ListDirectories(root)
+ if err != nil {
+ t.Fatalf("ListDirectories: %v", err)
+ }
+
+ if len(got.Entries) != 2 {
+ t.Fatalf("got %v, want both 'linked' and 'real'", got.Entries)
+ }
+}
+
+// A dangling symlink, and anything else os.Stat refuses, must be
+// skipped rather than failing the whole listing: Android's storage
+// root holds directories no app may enter, and one of them must not
+// cost the user the picker.
+func TestListDirectoriesSkipsUnreadable(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks need elevation on Windows")
+ }
+
+ root := t.TempDir()
+ if err := os.Mkdir(filepath.Join(root, "good"), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ dangling := filepath.Join(root, "dangling")
+ if err := os.Symlink(filepath.Join(root, "nowhere"), dangling); err != nil {
+ t.Fatalf("symlink: %v", err)
+ }
+
+ fe := &FrontendUtil{}
+
+ got, err := fe.ListDirectories(root)
+ if err != nil {
+ t.Fatalf("ListDirectories: %v", err)
+ }
+
+ if len(got.Entries) != 1 || got.Entries[0].Name != "good" {
+ t.Errorf("got %v, want only 'good'", got.Entries)
+ }
+}
+
+func TestListDirectoriesRejectsFiles(t *testing.T) {
+ root := t.TempDir()
+
+ file := filepath.Join(root, "track.mp3")
+ if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ fe := &FrontendUtil{}
+
+ if _, err := fe.ListDirectories(file); err == nil {
+ t.Error("listing a file should be an error")
+ }
+
+ if _, err := fe.ListDirectories(filepath.Join(root, "missing")); err == nil {
+ t.Error("listing a missing path should be an error")
+ }
+}
+
+// An empty path means "start where the picker should open", so the
+// frontend never has to know the platform.
+func TestListDirectoriesDefaultsToBrowseRoot(t *testing.T) {
+ fe := &FrontendUtil{}
+
+ got, err := fe.ListDirectories("")
+ if err != nil {
+ t.Skipf("default root not listable in this environment: %v", err)
+ }
+
+ if got.Path != fe.DefaultBrowseRoot() {
+ t.Errorf("Path = %q, want the default root %q", got.Path, fe.DefaultBrowseRoot())
+ }
+}
+
+// Parent is empty at a root, which is what tells the picker not to draw
+// an "up" control rather than making it reason about separators.
+func TestListDirectoriesRootHasNoParent(t *testing.T) {
+ fe := &FrontendUtil{}
+
+ got, err := fe.ListDirectories(string(filepath.Separator))
+ if err != nil {
+ t.Skipf("filesystem root not listable: %v", err)
+ }
+
+ if got.Parent != "" {
+ t.Errorf("Parent = %q at the root, want empty", got.Parent)
+ }
+}
+
+func TestCheckStorageAccess(t *testing.T) {
+ fe := &FrontendUtil{}
+
+ got := fe.CheckStorageAccess()
+ if got.Root == "" {
+ t.Error("Root should never be empty")
+ }
+
+ // The developer machine running this test can read its own home
+ // directory; the assertion is that the two fields agree, not that
+ // access is granted.
+ if got.Readable && got.Reason != "" {
+ t.Errorf("readable but Reason = %q", got.Reason)
+ }
+
+ if !got.Readable && got.Reason == "" {
+ t.Error("not readable but no Reason given")
+ }
+}
+
+// The picker's fallback is chosen from this, so a platform that gains
+// a working dialog must flip it here rather than in the frontend.
+func TestHasNativeDirectoryPicker(t *testing.T) {
+ fe := &FrontendUtil{}
+
+ want := runtime.GOOS != "android" && runtime.GOOS != "ios"
+ if got := fe.HasNativeDirectoryPicker(); got != want {
+ t.Errorf("HasNativeDirectoryPicker() on %s = %v, want %v", runtime.GOOS, got, want)
+ }
+}
diff --git a/backend/mediacontrols/mpris_linux.go b/backend/mediacontrols/mpris_linux.go
index ee3cd9d..b74f6d2 100644
--- a/backend/mediacontrols/mpris_linux.go
+++ b/backend/mediacontrols/mpris_linux.go
@@ -1,4 +1,11 @@
-//go:build linux
+//go:build linux && !android
+
+// MPRIS is a D-Bus desktop specification, and `android` implies the
+// `linux` build tag -- so without the `!android` this file compiled
+// into the Android app and went looking for a session bus that does
+// not exist. Desktop-Linux-only files need both halves; see Wails'
+// mobile guide, which names this as the Android analogue of
+// ios/darwin.
package mediacontrols
diff --git a/backend/mediacontrols/stub.go b/backend/mediacontrols/stub.go
index 0eccd12..d79c69f 100644
--- a/backend/mediacontrols/stub.go
+++ b/backend/mediacontrols/stub.go
@@ -1,4 +1,10 @@
-//go:build !linux
+//go:build !linux || android
+
+// Android is covered here rather than by mpris_linux.go: it satisfies
+// the `linux` tag but has no D-Bus session bus. Its real equivalent is
+// a MediaSession, which is Java-side work and not yet built -- so for
+// now the app simply has no lock-screen transport there, which is a
+// missing feature rather than a broken one.
package mediacontrols
diff --git a/build/android/app/src/main/AndroidManifest.xml b/build/android/app/src/main/AndroidManifest.xml
index 03d343a..c84d187 100644
--- a/build/android/app/src/main/AndroidManifest.xml
+++ b/build/android/app/src/main/AndroidManifest.xml
@@ -15,6 +15,48 @@
+
+
+
+
+
+
+
+
+
@@ -54,10 +96,16 @@
android:resource="@xml/file_paths" />
+
+ android:foregroundServiceType="mediaPlayback" />
diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java
index 7b71d3c..70b9c14 100644
--- a/build/android/app/src/main/java/com/wails/app/MainActivity.java
+++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java
@@ -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.
+ *
+ *
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().
+ *
+ *
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.
+ *
+ *
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
diff --git a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
index 7d599fa..a54b66d 100644
--- a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
+++ b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
@@ -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);
}
diff --git a/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts b/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts
index 3915b07..5041c81 100644
--- a/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts
+++ b/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts
@@ -10,6 +10,35 @@
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * CheckStorageAccess asks the filesystem rather than the permission
+ * system.
+ *
+ * On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
+ * grants on a Settings screen rather than in a dialog — so it can be
+ * refused, revoked later, or simply never answered, and the permission
+ * API is one more thing that can disagree with reality. Reading the
+ * directory is the question the library scanner will actually ask, so
+ * it is the one worth answering.
+ *
+ * It is deliberately not an error return: "we cannot read your music
+ * yet" is a state the UI renders, not a failure of the call.
+ */
+export function CheckStorageAccess(): $CancellablePromise<$models.StorageAccess> {
+ return $Call.ByID(1661980006);
+}
+
+/**
+ * DefaultBrowseRoot is where a folder picker should open.
+ */
+export function DefaultBrowseRoot(): $CancellablePromise {
+ return $Call.ByID(497852148);
+}
+
/**
* DirectoryPicker opens a directory selection dialog.
*
@@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise {
return $Call.ByID(3245034282);
}
+/**
+ * HasNativeDirectoryPicker reports whether this platform can open a
+ * directory dialog at all.
+ *
+ * It is asked of the backend rather than tested in the frontend with
+ * `System.IsAndroid()`, for three reasons. The dialog *is* backend code
+ * — `DirectoryPicker` above — so this is the same package saying what
+ * it can do. It answers for iOS too without the frontend enumerating
+ * platforms. And it makes the frontend's fallback testable through the
+ * ordinary transport fake instead of a module mock of the Wails
+ * runtime, whose platform helpers read build constants.
+ */
+export function HasNativeDirectoryPicker(): $CancellablePromise {
+ return $Call.ByID(1028901937);
+}
+
/**
* ImageFilePicker opens a file selection dialog filtered to image
* files (JPEG, PNG). Returns the selected file path, or empty
@@ -29,6 +74,30 @@ export function ImageFilePicker(): $CancellablePromise {
return $Call.ByID(3408786006);
}
+/**
+ * ListDirectories returns the directories directly inside path, so the
+ * frontend can draw a folder picker.
+ *
+ * **It exists because Android has no directory picker.** Wails' file
+ * dialog can choose directories on every desktop platform, and on
+ * Android it returns an error: the Storage Access Framework yields tree
+ * URIs rather than filesystem paths, and a path is what this app's
+ * entire library model is keyed on. Rather than teach the backend about
+ * tree URIs, the app browses the filesystem itself — which it can do
+ * because it holds all-files access (see the manifest).
+ *
+ * Three rules, each of which a picker gets wrong if it is not stated:
+ * only directories are returned, because the caller is choosing a
+ * library root and files are noise; unreadable children are skipped
+ * rather than failing the whole listing, since Android's storage root
+ * contains directories no app may enter; and hidden directories are
+ * omitted, because a music library is not in one and `.thumbnails`
+ * alone would swamp the list.
+ */
+export function ListDirectories(path: string): $CancellablePromise<$models.DirListing> {
+ return $Call.ByID(692624856, path);
+}
+
/**
* PlaylistFilePicker opens a file selection dialog filtered
* to M3U/M3U8 playlist files. Multiple files may be selected.
diff --git a/frontend/bindings/yellowjacket/backend/frontendutil/index.ts b/frontend/bindings/yellowjacket/backend/frontendutil/index.ts
index 5c57627..dd55c18 100644
--- a/frontend/bindings/yellowjacket/backend/frontendutil/index.ts
+++ b/frontend/bindings/yellowjacket/backend/frontendutil/index.ts
@@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js";
export {
FrontendUtil
};
+
+export type {
+ DirEntry,
+ DirListing,
+ StorageAccess
+} from "./models.js";
diff --git a/frontend/bindings/yellowjacket/backend/frontendutil/models.ts b/frontend/bindings/yellowjacket/backend/frontendutil/models.ts
new file mode 100644
index 0000000..07c0668
--- /dev/null
+++ b/frontend/bindings/yellowjacket/backend/frontendutil/models.ts
@@ -0,0 +1,34 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * DirEntry is one selectable directory in a listing.
+ */
+export interface DirEntry {
+ "name": string;
+ "path": string;
+}
+
+/**
+ * DirListing is one level of the filesystem, as a folder picker needs
+ * it: where we are, what is above, and the directories below.
+ *
+ * Parent is empty at a root, which is what tells the UI not to draw an
+ * "up" affordance rather than having it compute that from the path
+ * separator.
+ */
+export interface DirListing {
+ "path": string;
+ "parent": string;
+ "entries": DirEntry[] | null;
+}
+
+/**
+ * StorageAccess reports whether the app can actually read the place the
+ * user's music lives.
+ */
+export interface StorageAccess {
+ "root": string;
+ "readable": boolean;
+ "reason": string;
+}
diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts
index 986ac62..cf7d9b9 100644
--- a/frontend/src/components/config-page/config-page.ts
+++ b/frontend/src/components/config-page/config-page.ts
@@ -19,7 +19,6 @@ import {
SetQueueFallback,
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
-import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { notificationStore } from '@store/notification-store';
import { describeError, explainError } from '@utils/describe-error';
import type * as library from '@go/library/models.js';
@@ -50,6 +49,7 @@ import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
import { list } from '@utils/binding';
+import { pickDirectory } from '../../utils/pick-directory';
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
@@ -916,7 +916,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
let dir = '';
try {
- dir = await DirectoryPicker();
+ dir = (await pickDirectory()) ?? '';
if (!dir) return;
diff --git a/frontend/src/components/config-page/download-clients.ts b/frontend/src/components/config-page/download-clients.ts
index cdeb150..cf36c44 100644
--- a/frontend/src/components/config-page/download-clients.ts
+++ b/frontend/src/components/config-page/download-clients.ts
@@ -14,7 +14,6 @@ import type {
ProviderField,
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
-import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
import { SetPreferences } from '@go/download/service.js';
import type * as download from '@go/download/models.js';
@@ -23,6 +22,7 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
+import { pickDirectory } from '../../utils/pick-directory';
/**
* Allowed audio formats for auto-download, mirrored from
@@ -580,7 +580,7 @@ export class DownloadClients extends LitElement {
private browseForFolder = async (field: ProviderField) => {
try {
- const dir = await DirectoryPicker();
+ const dir = await pickDirectory();
if (dir) {
this.draft = { ...this.draft, [field.key]: dir };
diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts
index 96316ff..447ac6b 100644
--- a/frontend/src/components/first-run-wizard/first-run-wizard.ts
+++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts
@@ -6,9 +6,9 @@ import {
AddLibrary,
GetAllLibrariesWithTrackCounts,
} from '@go/library/library.js';
-import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { describeError, explainError } from '@utils/describe-error';
import { nameDialogsIn } from '@utils/name-dialog';
+import { pickDirectory } from '../../utils/pick-directory';
/**
* First-run setup wizard.
@@ -243,7 +243,7 @@ export class FirstRunWizard extends LitElement {
this.errorMessage = '';
try {
- const dir = await DirectoryPicker();
+ const dir = await pickDirectory();
if (dir) this.selectedDirectory = dir;
} catch (err) {
diff --git a/frontend/src/components/folder-picker/folder-picker.ts b/frontend/src/components/folder-picker/folder-picker.ts
new file mode 100644
index 0000000..5a5728c
--- /dev/null
+++ b/frontend/src/components/folder-picker/folder-picker.ts
@@ -0,0 +1,279 @@
+/**
+ * Choosing a directory, where the platform will not do it for us.
+ *
+ * Wails' file dialog can select directories on every desktop platform.
+ * On Android it returns an error, because the Storage Access Framework
+ * yields tree URIs rather than filesystem paths — and a path is what
+ * this app's whole library model is keyed on. So the app browses the
+ * filesystem itself, through `ListDirectories`, which it can do because
+ * it holds all-files access.
+ *
+ * Deliberately not a general file browser: it lists directories only,
+ * because the thing being chosen is a library root.
+ *
+ * The shape is `confirm-dialog`'s — a promise-returning `choose()` on a
+ * `wa-dialog`, so callers `await` a path or `null` and there is no
+ * second dialog pattern in the codebase.
+ */
+import { LitElement, css, html, nothing } from 'lit';
+import { customElement, query, state } from 'lit/decorators.js';
+import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
+
+import {
+ DefaultBrowseRoot,
+ ListDirectories,
+} from '@go/frontendutil/frontendutil.js';
+
+import { designTokens } from '../../styles/tokens.css';
+import { srOnly } from '../../styles/sr-only.css';
+import { describeError } from '../../utils/describe-error';
+import { nameDialogsIn } from '../../utils/name-dialog';
+
+interface Entry {
+ name: string;
+ path: string;
+}
+
+@customElement('folder-picker')
+export class FolderPicker extends LitElement {
+ @query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
+
+ @state() private path = '';
+ @state() private parent = '';
+ @state() private entries: Entry[] = [];
+ @state() private loading = false;
+ @state() private errorMessage = '';
+ @state() private isOpen = false;
+
+ private settle: ((path: string | null) => void) | null = null;
+
+ static override styles = [
+ designTokens,
+ srOnly,
+ css`
+ :host {
+ display: contents;
+ }
+
+ wa-dialog::part(dialog) {
+ background: var(--yj-bg-surface, #212529);
+ color: var(--yj-text-primary, #fff);
+ }
+
+ .current {
+ color: var(--yj-text-secondary, #b3b3b3);
+ font-size: var(--yj-text-sm, 0.8125rem);
+ margin-bottom: 8px;
+ overflow-wrap: anywhere;
+ }
+
+ ul {
+ border: 1px solid var(--yj-border, #495057);
+ border-radius: 4px;
+ list-style: none;
+ margin: 0;
+ max-height: 45vh;
+ min-height: 8em;
+ overflow-y: auto;
+ padding: 0;
+ }
+
+ li button {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ font: inherit;
+ gap: 8px;
+ padding: 10px 12px;
+ text-align: left;
+ width: 100%;
+ }
+
+ li button:hover,
+ li button:focus-visible {
+ background: var(--yj-bg-elevated, #343a40);
+ }
+
+ .empty {
+ color: var(--yj-text-secondary, #b3b3b3);
+ padding: 12px;
+ }
+
+ .error {
+ color: var(--yj-error-text, #ff8787);
+ padding: 8px 0;
+ }
+
+ .actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 12px;
+ }
+
+ .actions button {
+ background: var(--yj-bg-elevated, #343a40);
+ border: 1px solid var(--yj-border, #495057);
+ border-radius: 4px;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ padding: 6px 14px;
+ }
+
+ .actions button.primary {
+ background: var(--yj-accent, #ffd43b);
+ border-color: var(--yj-accent, #ffd43b);
+ color: var(--yj-accent-fg, #000);
+ }
+ `,
+ ];
+
+ /** Browse. Resolves with an absolute path, or null if cancelled. */
+ async choose(startAt?: string): Promise {
+ this.settle?.(null);
+ this.settle = null;
+
+ let start = startAt ?? '';
+
+ if (!start) {
+ try {
+ start = await DefaultBrowseRoot();
+ } catch {
+ start = '';
+ }
+ }
+
+ this.isOpen = true;
+ await this.load(start);
+ await this.updateComplete;
+
+ if (this.dialog) this.dialog.open = true;
+
+ return new Promise((resolve) => {
+ this.settle = resolve;
+ });
+ }
+
+ private async load(path: string): Promise {
+ this.loading = true;
+ this.errorMessage = '';
+
+ try {
+ const listing = await ListDirectories(path);
+
+ this.path = listing.path;
+ this.parent = listing.parent;
+ this.entries = listing.entries ?? [];
+ } catch (err) {
+ // A directory that cannot be read is not a failed picker —
+ // stay where we are and say so, or the user is stranded
+ // with an empty dialog and no way back.
+ this.errorMessage = describeError(
+ err,
+ 'That folder could not be opened.',
+ );
+ console.error('folder-picker: listing failed:', err);
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private close(path: string | null): void {
+ const settle = this.settle;
+
+ this.settle = null;
+
+ if (this.dialog) this.dialog.open = false;
+ this.isOpen = false;
+ settle?.(path);
+ }
+
+ override updated(): void {
+ nameDialogsIn(this.shadowRoot);
+ }
+
+ override render() {
+ if (!this.isOpen) return nothing;
+
+ return html`
+ this.close(null)}
+ >
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'folder-picker': FolderPicker;
+ }
+}
diff --git a/frontend/src/utils/pick-directory.ts b/frontend/src/utils/pick-directory.ts
new file mode 100644
index 0000000..18033b9
--- /dev/null
+++ b/frontend/src/utils/pick-directory.ts
@@ -0,0 +1,87 @@
+/**
+ * "Ask the user for a folder", once.
+ *
+ * Three call sites want a directory — the first-run wizard, the library
+ * settings and the download clients' save path — and on desktop all
+ * three can use the platform's own dialog. On Android that dialog
+ * *returns an error*: the Storage Access Framework yields tree URIs
+ * rather than filesystem paths, and a path is what this app's library
+ * model is keyed on.
+ *
+ * So the platform test lives here rather than at each call site, which
+ * is the same rule `utils/binding.ts` and `utils/library-status.ts`
+ * follow: a fact about the platform is stated once, at the boundary.
+ *
+ * *Which* platform is asked of the backend, not of the Wails runtime's
+ * `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 this testable through the ordinary transport fake
+ * rather than a module mock.
+ *
+ * Returns the chosen path, or `null` if the user cancelled. Callers
+ * treat those the same way they always did — a falsy result means "no
+ * change" — so adopting this is a one-line edit at each site.
+ */
+import {
+ DirectoryPicker,
+ HasNativeDirectoryPicker,
+} from "@go/frontendutil/frontendutil.js";
+
+import type { FolderPicker } from "../components/folder-picker/folder-picker";
+
+let picker: FolderPicker | null = null;
+let native: Promise | null = null;
+
+/**
+ * Asked once per session and remembered: it cannot change while the app
+ * is running, and a folder picker should not pay a round trip to find
+ * out which kind it is.
+ */
+function hasNative(): Promise {
+ native ??= HasNativeDirectoryPicker().catch(() => true);
+
+ return native;
+}
+
+/** Testing seam: forget the cached platform answer. */
+export function resetDirectoryPickerCache(): void {
+ native = null;
+ picker = null;
+}
+
+/**
+ * The in-app browser is mounted on first use and then kept.
+ *
+ * Mounting it on demand and awaiting its module in the same update as
+ * `showModal()` is the trap `index.ts` documents for views — so the
+ * element is created, appended and *then* asked to open, on separate
+ * turns.
+ */
+async function inAppPicker(): Promise {
+ if (picker) return picker;
+
+ await import("../components/folder-picker/folder-picker");
+
+ const el = document.createElement("folder-picker");
+
+ document.body.appendChild(el);
+ picker = el;
+
+ return el;
+}
+
+/** Ask for a directory. Resolves to an absolute path, or null. */
+export async function pickDirectory(startAt?: string): Promise {
+ if (!(await hasNative())) {
+ const el = await inAppPicker();
+
+ return el.choose(startAt);
+ }
+
+ // The desktop dialog returns '' when dismissed; normalise that to
+ // null so every caller has one falsy case to handle rather than
+ // two.
+ const chosen = await DirectoryPicker();
+
+ return chosen === "" ? null : chosen;
+}
diff --git a/frontend/test/components/folder-picker.test.ts b/frontend/test/components/folder-picker.test.ts
new file mode 100644
index 0000000..07610ad
--- /dev/null
+++ b/frontend/test/components/folder-picker.test.ts
@@ -0,0 +1,206 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { wails } from '../support/wails-fake';
+
+import type { FolderPicker } from '@components/folder-picker/folder-picker';
+
+const listings: Record = {
+ '/storage/emulated/0': {
+ path: '/storage/emulated/0',
+ parent: '/storage/emulated',
+ entries: [
+ { name: 'Music', path: '/storage/emulated/0/Music' },
+ { name: 'Podcasts', path: '/storage/emulated/0/Podcasts' },
+ ],
+ },
+ '/storage/emulated/0/Music': {
+ path: '/storage/emulated/0/Music',
+ parent: '/storage/emulated/0',
+ entries: [],
+ },
+};
+
+/**
+ * `pickDirectory` imports the picker's chunk before it can create the
+ * element, so the element does not exist on the turn the call is made.
+ * That is deliberate -- mounting a dialog and calling showModal() in
+ * one update is the trap `index.ts` documents -- so the test waits for
+ * it rather than assuming it is synchronous.
+ */
+async function host(): Promise {
+ for (let i = 0; i < 50; i++) {
+ const el = document.querySelector('folder-picker');
+
+ if (el) {
+ await el.updateComplete;
+
+ return el;
+ }
+
+ await new Promise((r) => setTimeout(r, 10));
+ }
+
+ throw new Error('folder-picker did not mount itself');
+}
+
+function click(el: FolderPicker, testid: string): void {
+ el.shadowRoot
+ ?.querySelector(`[data-testid="${testid}"]`)
+ ?.click();
+}
+
+beforeEach(() => {
+ wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true);
+ wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music');
+ wails.stub('frontendutil.FrontendUtil.DefaultBrowseRoot', '/storage/emulated/0');
+ wails.stub('frontendutil.FrontendUtil.ListDirectories', (path: string) => {
+ const listing = listings[path || '/storage/emulated/0'];
+
+ if (!listing) throw new Error('permission denied');
+
+ return listing;
+ });
+});
+
+afterEach(() => {
+ document.querySelector('folder-picker')?.remove();
+ wails.reset();
+});
+
+describe('pickDirectory', () => {
+ it('uses the platform dialog off Android', async () => {
+ const { pickDirectory, resetDirectoryPickerCache } = await import(
+ '@utils/pick-directory'
+ );
+
+ resetDirectoryPickerCache();
+
+ await expect(pickDirectory()).resolves.toBe('/home/logan/Music');
+ expect(document.querySelector('folder-picker')).toBeNull();
+ });
+
+ it('normalises the desktop dialog\u2019s empty string to null', async () => {
+ wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '');
+
+ const { pickDirectory, resetDirectoryPickerCache } = await import(
+ '@utils/pick-directory'
+ );
+
+ resetDirectoryPickerCache();
+
+ await expect(pickDirectory()).resolves.toBeNull();
+ });
+
+ it('browses in-app on Android, and never opens the platform dialog', async () => {
+ wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
+
+ const { pickDirectory, resetDirectoryPickerCache } = await import(
+ '@utils/pick-directory'
+ );
+
+ resetDirectoryPickerCache();
+ const answer = pickDirectory();
+
+ const el = await host();
+
+ await new Promise((r) => setTimeout(r, 0));
+ await el.updateComplete;
+
+ click(el, 'folder-picker-select');
+
+ await expect(answer).resolves.toBe('/storage/emulated/0');
+ });
+
+ it('resolves null when the browser is cancelled', async () => {
+ wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
+
+ const { pickDirectory, resetDirectoryPickerCache } = await import(
+ '@utils/pick-directory'
+ );
+
+ resetDirectoryPickerCache();
+ const answer = pickDirectory();
+
+ const el = await host();
+
+ await new Promise((r) => setTimeout(r, 0));
+ await el.updateComplete;
+
+ el.shadowRoot
+ ?.querySelectorAll('.actions button')[0]
+ ?.click();
+
+ await expect(answer).resolves.toBeNull();
+ });
+
+ it('descends into a folder and returns the one it is showing', async () => {
+ wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
+
+ const { pickDirectory, resetDirectoryPickerCache } = await import(
+ '@utils/pick-directory'
+ );
+
+ resetDirectoryPickerCache();
+ const answer = pickDirectory();
+
+ const el = await host();
+
+ await new Promise((r) => setTimeout(r, 0));
+ await el.updateComplete;
+
+ const music = [
+ ...(el.shadowRoot?.querySelectorAll(
+ '[data-testid="folder-picker-list"] button',
+ ) ?? []),
+ ].find((b) => b.textContent?.includes('Music'));
+
+ music?.click();
+ await new Promise((r) => setTimeout(r, 0));
+ await el.updateComplete;
+
+ click(el, 'folder-picker-select');
+
+ await expect(answer).resolves.toBe('/storage/emulated/0/Music');
+ });
+
+ /**
+ * A directory that cannot be read is not a failed picker. Android's
+ * storage root holds directories no app may enter, and stranding the
+ * user in an empty dialog with no way back is worse than saying so.
+ */
+ it('stays put and explains when a folder cannot be opened', async () => {
+ wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
+
+ const { pickDirectory, resetDirectoryPickerCache } = await import(
+ '@utils/pick-directory'
+ );
+
+ resetDirectoryPickerCache();
+ const answer = pickDirectory();
+
+ const el = await host();
+
+ await new Promise((r) => setTimeout(r, 0));
+ await el.updateComplete;
+
+ // 'Podcasts' has no listing, so ListDirectories rejects.
+ const bad = [
+ ...(el.shadowRoot?.querySelectorAll(
+ '[data-testid="folder-picker-list"] button',
+ ) ?? []),
+ ].find((b) => b.textContent?.includes('Podcasts'));
+
+ bad?.click();
+ await new Promise((r) => setTimeout(r, 0));
+ await el.updateComplete;
+
+ expect(el.shadowRoot?.querySelector('[role="alert"]')).toBeTruthy();
+ expect(
+ el.shadowRoot?.querySelector('[data-testid="folder-picker-path"]')
+ ?.textContent,
+ ).toContain('/storage/emulated/0');
+
+ click(el, 'folder-picker-select');
+ await expect(answer).resolves.toBe('/storage/emulated/0');
+ });
+});