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)} + > +

+ ${this.path || '\u2026'} +

+ + ${this.errorMessage + ? html`` + : nothing} + +
    + ${this.parent + ? html`
  • + +
  • ` + : nothing} + ${this.entries.map( + (entry) => html` +
  • + +
  • + `, + )} + ${!this.loading && this.entries.length === 0 + ? html`
  • No folders here.
  • ` + : nothing} +
+ + +

+ ${this.loading + ? 'Loading folders' + : `${this.entries.length} folders in ${this.path}`} +

+ +
+ + +
+ + `; + } +} + +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'); + }); +});