Compare commits
2
Commits
78576b8da9
...
ced537ecf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced537ecf2 | ||
|
|
e14a34fccf |
No files matched your search
@@ -1,5 +1,13 @@
|
|||||||
# 016 — What Android parity would actually take
|
# 016 — What Android parity would actually take
|
||||||
|
|
||||||
|
> **Status: A1, A2 and A3 are done** (commit "let the app reach the
|
||||||
|
> user's music"). The direction taken is **option 1, the full
|
||||||
|
> librarian**: `MANAGE_EXTERNAL_STORAGE` plus an in-app folder browser,
|
||||||
|
> which keeps the path-keyed model intact. A4 (MediaSession and audio
|
||||||
|
> focus) and B1/B2 remain. The sections below are kept as written,
|
||||||
|
> because they are the argument the decision rests on — see "What is
|
||||||
|
> left" at the end for the current state.
|
||||||
|
|
||||||
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
|
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
|
||||||
versioned, and publishes from CI. This is the assessment of what stands
|
versioned, and publishes from CI. This is the assessment of what stands
|
||||||
between that and an Android app worth installing.
|
between that and an Android app worth installing.
|
||||||
@@ -202,3 +210,39 @@ Cheap, independently useful, and each unblocks measurement:
|
|||||||
wizard still blocks pointer events, so an Android user sees a dead
|
wizard still blocks pointer events, so an Android user sees a dead
|
||||||
screen rather than a sentence. Even under option 3 this is the right
|
screen rather than a sentence. Even under option 3 this is the right
|
||||||
behaviour.
|
behaviour.
|
||||||
|
|
||||||
|
|
||||||
|
## What is left (updated after A1-A3)
|
||||||
|
|
||||||
|
**A4, playback that survives the screen locking.** The manifest and the
|
||||||
|
service are typed `mediaPlayback` now and the permission is declared,
|
||||||
|
so the foundation is in place; what is missing is a `MediaSession`, a
|
||||||
|
transport notification and audio-focus handling. The plumbing for it
|
||||||
|
exists and needs no new JNI: Go can call
|
||||||
|
`application.Android.StartForegroundService(json)` (exported by Wails),
|
||||||
|
and Java can call `WailsBridge.emitEvent(name, json)` back into the
|
||||||
|
application event bus, which Go subscribes to. So the shape is a JSON
|
||||||
|
payload of title/artist/state going out and transport commands coming
|
||||||
|
back, with `backend/mediacontrols` gaining an Android handler beside
|
||||||
|
the MPRIS one — the interface it already defines is the right shape.
|
||||||
|
|
||||||
|
Audio focus is the half that is easy to forget and the more important
|
||||||
|
one: pause on a phone call, duck for a notification, pause on headphone
|
||||||
|
unplug. `oto` will happily keep writing to a stream nobody can hear.
|
||||||
|
|
||||||
|
**B1, the x86_64 half of the APK**, which cannot run on any Android
|
||||||
|
because of the modernc `lstat` seccomp trap. Still undecided; dropping
|
||||||
|
it is a five-minute change that halves the artifact.
|
||||||
|
|
||||||
|
**B2, the desktop shell.** Untouched and the largest remaining piece.
|
||||||
|
|
||||||
|
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||||
|
with all-files access, `tagwriter` can write in place.
|
||||||
|
|
||||||
|
### What A1-A3 did not answer
|
||||||
|
|
||||||
|
Nothing here has been observed on a device. The permission flow in
|
||||||
|
particular is the kind of thing that behaves differently across OEM
|
||||||
|
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
|
||||||
|
implemented inconsistently, which is why there is a fallback to the
|
||||||
|
global list, and neither path has been exercised.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package mediacontrols
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
package mediacontrols
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,48 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||||
@@ -54,10 +96,16 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</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
|
<service
|
||||||
android:name=".WailsForegroundService"
|
android:name=".WailsForegroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -11,10 +11,13 @@ import android.net.ConnectivityManager;
|
|||||||
import android.net.Network;
|
import android.net.Network;
|
||||||
import android.net.NetworkCapabilities;
|
import android.net.NetworkCapabilities;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.Manifest;
|
||||||
import android.os.BatteryManager;
|
import android.os.BatteryManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.Environment;
|
||||||
import android.os.PowerManager;
|
import android.os.PowerManager;
|
||||||
|
import android.provider.Settings;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.BitmapFactory;
|
import android.graphics.BitmapFactory;
|
||||||
@@ -89,6 +92,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
bridge = new WailsBridge(this);
|
bridge = new WailsBridge(this);
|
||||||
bridge.initialize();
|
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
|
// Set up WebView
|
||||||
setupWebView();
|
setupWebView();
|
||||||
|
|
||||||
@@ -96,6 +103,78 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
loadApplication();
|
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")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
private void setupWebView() {
|
private void setupWebView() {
|
||||||
webView = findViewById(R.id.webview);
|
webView = findViewById(R.id.webview);
|
||||||
@@ -768,6 +847,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (bridge != null) {
|
if (bridge != null) {
|
||||||
bridge.onResume();
|
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
|
@Override
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ public class WailsForegroundService extends android.app.Service {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
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 {
|
} else {
|
||||||
startForeground(NOTIFICATION_ID, n);
|
startForeground(NOTIFICATION_ID, n);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,35 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
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.
|
* DirectoryPicker opens a directory selection dialog.
|
||||||
*
|
*
|
||||||
@@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(3245034282);
|
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
|
* ImageFilePicker opens a file selection dialog filtered to image
|
||||||
* files (JPEG, PNG). Returns the selected file path, or empty
|
* files (JPEG, PNG). Returns the selected file path, or empty
|
||||||
@@ -29,6 +74,30 @@ export function ImageFilePicker(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(3408786006);
|
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
|
* PlaylistFilePicker opens a file selection dialog filtered
|
||||||
* to M3U/M3U8 playlist files. Multiple files may be selected.
|
* to M3U/M3U8 playlist files. Multiple files may be selected.
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js";
|
|||||||
export {
|
export {
|
||||||
FrontendUtil
|
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,
|
SetQueueFallback,
|
||||||
} from '@go/config/config.js';
|
} from '@go/config/config.js';
|
||||||
import { GetIndexStatus } from '@go/explore/service.js';
|
import { GetIndexStatus } from '@go/explore/service.js';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
|
||||||
import { notificationStore } from '@store/notification-store';
|
import { notificationStore } from '@store/notification-store';
|
||||||
import { describeError, explainError } from '@utils/describe-error';
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
import type * as library from '@go/library/models.js';
|
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 { shortcutsStore } from '../../store/shortcuts-store';
|
||||||
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
||||||
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
||||||
@@ -916,7 +916,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
let dir = '';
|
let dir = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dir = await DirectoryPicker();
|
dir = (await pickDirectory()) ?? '';
|
||||||
|
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
ProviderField,
|
ProviderField,
|
||||||
} from '@store/download-store';
|
} from '@store/download-store';
|
||||||
import { downloadStore } 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 { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
|
||||||
import { SetPreferences } from '@go/download/service.js';
|
import { SetPreferences } from '@go/download/service.js';
|
||||||
import type * as download from '@go/download/models.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 { describeError, explainError } from '@utils/describe-error';
|
||||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||||
import './config-section';
|
import './config-section';
|
||||||
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed audio formats for auto-download, mirrored from
|
* Allowed audio formats for auto-download, mirrored from
|
||||||
@@ -580,7 +580,7 @@ export class DownloadClients extends LitElement {
|
|||||||
|
|
||||||
private browseForFolder = async (field: ProviderField) => {
|
private browseForFolder = async (field: ProviderField) => {
|
||||||
try {
|
try {
|
||||||
const dir = await DirectoryPicker();
|
const dir = await pickDirectory();
|
||||||
|
|
||||||
if (dir) {
|
if (dir) {
|
||||||
this.draft = { ...this.draft, [field.key]: dir };
|
this.draft = { ...this.draft, [field.key]: dir };
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
AddLibrary,
|
AddLibrary,
|
||||||
GetAllLibrariesWithTrackCounts,
|
GetAllLibrariesWithTrackCounts,
|
||||||
} from '@go/library/library.js';
|
} from '@go/library/library.js';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
|
||||||
import { describeError, explainError } from '@utils/describe-error';
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
import { nameDialogsIn } from '@utils/name-dialog';
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run setup wizard.
|
* First-run setup wizard.
|
||||||
@@ -243,7 +243,7 @@ export class FirstRunWizard extends LitElement {
|
|||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dir = await DirectoryPicker();
|
const dir = await pickDirectory();
|
||||||
|
|
||||||
if (dir) this.selectedDirectory = dir;
|
if (dir) this.selectedDirectory = dir;
|
||||||
} catch (err) {
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user