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
+181
View File
@@ -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
}
}
+207
View File
@@ -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)
}
}
+8 -1
View File
@@ -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
+7 -1
View File
@@ -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
+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);
}
@@ -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<string> {
return $Call.ByID(497852148);
}
/**
* DirectoryPicker opens a directory selection dialog.
*
@@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise<string> {
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<boolean> {
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<string> {
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.
@@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js";
export {
FrontendUtil
};
export type {
DirEntry,
DirListing,
StorageAccess
} from "./models.js";
@@ -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;
}
@@ -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;
@@ -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 };
@@ -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) {
@@ -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<string | null> {
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<string | null>((resolve) => {
this.settle = resolve;
});
}
private async load(path: string): Promise<void> {
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`
<wa-dialog
label="Choose a folder"
data-testid="folder-picker"
@wa-hide=${() => this.close(null)}
>
<p class="current" data-testid="folder-picker-path">
${this.path || '\u2026'}
</p>
${this.errorMessage
? html`<p class="error" role="alert">
${this.errorMessage}
</p>`
: nothing}
<ul data-testid="folder-picker-list">
${this.parent
? html`<li>
<button
@click=${() => void this.load(this.parent)}
data-testid="folder-picker-up"
>
<span aria-hidden="true">\u2191</span> Up one
level
</button>
</li>`
: nothing}
${this.entries.map(
(entry) => html`
<li>
<button
@click=${() => void this.load(entry.path)}
>
<span aria-hidden="true">\u{1F4C1}</span>
${entry.name}
</button>
</li>
`,
)}
${!this.loading && this.entries.length === 0
? html`<li class="empty">No folders here.</li>`
: nothing}
</ul>
<!--
The live region is in the DOM before it has anything to
say, because most screen readers announce a change to a
region they are already watching and ignore one that
appears with its content already in it.
-->
<p class="sr-only" role="status" aria-live="polite">
${this.loading
? 'Loading folders'
: `${this.entries.length} folders in ${this.path}`}
</p>
<div class="actions">
<button @click=${() => this.close(null)}>Cancel</button>
<button
class="primary"
?disabled=${!this.path}
@click=${() => this.close(this.path)}
data-testid="folder-picker-select"
>
Use this folder
</button>
</div>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'folder-picker': FolderPicker;
}
}
+87
View File
@@ -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<boolean> | 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<boolean> {
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<FolderPicker> {
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<string | null> {
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;
}
@@ -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<string, unknown> = {
'/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<FolderPicker> {
for (let i = 0; i < 50; i++) {
const el = document.querySelector<FolderPicker>('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<HTMLButtonElement>(`[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<HTMLButtonElement>('.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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[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');
});
});