Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced537ecf2 | ||
|
|
e14a34fccf |
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
versioned, and publishes from CI. This is the assessment of what stands
|
||||
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
|
||||
screen rather than a sentence. Even under option 3 this is the right
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,6 +15,48 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!--
|
||||
Playback has to survive the screen locking, and that needs a
|
||||
foreground service typed mediaPlayback rather than dataSync. The
|
||||
type in the <service> element and the permission here must agree,
|
||||
or startForeground throws at runtime.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!--
|
||||
Reading the user's music.
|
||||
|
||||
READ_MEDIA_AUDIO is the Android 13+ grant and READ_EXTERNAL_STORAGE
|
||||
is its predecessor, capped so it is not requested where it no
|
||||
longer applies. Both give access through **MediaStore**.
|
||||
|
||||
MANAGE_EXTERNAL_STORAGE is what gives access through the
|
||||
*filesystem*, and this app needs it rather than merely preferring
|
||||
it: `audio_files.file_path` is the primary key of ownership, the
|
||||
scanner walks a directory the user chose, and every
|
||||
GetFilePathsBy... query exists to hand a path to the player.
|
||||
MediaStore offers no stable directory to walk and no way to write
|
||||
a tag back in place, so the alternative is not "more work" but a
|
||||
different application.
|
||||
|
||||
It is a Play-restricted permission, granted on a Settings screen
|
||||
rather than in a dialog. That is acceptable *here* only because
|
||||
this app is distributed as an APK through the package registry and
|
||||
not through Play — see docs/android-release.md. If it ever targets
|
||||
Play, this is the line that has to go, and plan 016 says what
|
||||
would replace it.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||
@@ -54,10 +96,16 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
mediaPlayback, not the scaffold's dataSync: this app's reason
|
||||
for staying alive in the background is that a song is
|
||||
playing, and Android matches the declared type against what
|
||||
the service actually does.
|
||||
-->
|
||||
<service
|
||||
android:name=".WailsForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -11,10 +11,13 @@ import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.Uri;
|
||||
import android.Manifest;
|
||||
import android.os.BatteryManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.PowerManager;
|
||||
import android.provider.Settings;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
@@ -89,6 +92,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
bridge = new WailsBridge(this);
|
||||
bridge.initialize();
|
||||
|
||||
// Ask for access to the user's music before the frontend has
|
||||
// anything to say about it. See ensureStorageAccess().
|
||||
ensureStorageAccess();
|
||||
|
||||
// Set up WebView
|
||||
setupWebView();
|
||||
|
||||
@@ -96,6 +103,78 @@ public class MainActivity extends AppCompatActivity {
|
||||
loadApplication();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain access to the user's music.
|
||||
*
|
||||
* <p>This app is a library manager: its database is keyed on file
|
||||
* paths, its scanner walks a directory the user chose, and its tag
|
||||
* writer rewrites files in place. MediaStore offers none of those,
|
||||
* so the app holds MANAGE_EXTERNAL_STORAGE — which is granted on a
|
||||
* Settings screen rather than in a dialog, and therefore cannot be
|
||||
* requested with requestPermissions().
|
||||
*
|
||||
* <p>The screen is opened on every cold start until access exists,
|
||||
* because without it the app can see nothing at all and there is no
|
||||
* degraded mode worth offering. Returning from it lands in
|
||||
* onResume, which re-checks and tells the frontend.
|
||||
*
|
||||
* <p>Below Android 11 there is no all-files concept and plain
|
||||
* READ_EXTERNAL_STORAGE is both sufficient and a normal dialog.
|
||||
*/
|
||||
private void ensureStorageAccess() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (Environment.isExternalStorageManager()) {
|
||||
return;
|
||||
}
|
||||
// The per-app screen is the one that can actually grant it.
|
||||
// A few OEM builds do not implement it, so fall back to the
|
||||
// global list rather than leaving the user with nothing.
|
||||
try {
|
||||
startActivity(new Intent(
|
||||
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
Uri.parse("package:" + getPackageName())));
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "per-app all-files screen unavailable: " + e.getMessage());
|
||||
try {
|
||||
startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION));
|
||||
} catch (Exception e2) {
|
||||
Log.w(TAG, "no all-files settings screen at all: " + e2.getMessage());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1010);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app can currently read the user's music, by the same
|
||||
* test the Go side uses.
|
||||
*/
|
||||
private boolean hasStorageAccess() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return Environment.isExternalStorageManager();
|
||||
}
|
||||
return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the frontend whether music is readable. Emitted on resume
|
||||
* rather than only at startup, because the grant happens on a
|
||||
* Settings screen in another task and the way back is a resume.
|
||||
*/
|
||||
private void emitStorageAccess() {
|
||||
if (bridge == null) {
|
||||
return;
|
||||
}
|
||||
bridge.emitEvent("android:storageAccess",
|
||||
"{\"granted\":" + (hasStorageAccess() ? "true" : "false") + "}");
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private void setupWebView() {
|
||||
webView = findViewById(R.id.webview);
|
||||
@@ -768,6 +847,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (bridge != null) {
|
||||
bridge.onResume();
|
||||
}
|
||||
// The all-files grant happens on a Settings screen in another
|
||||
// task, so a resume is how the app finds out it was given.
|
||||
emitStorageAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -58,7 +58,12 @@ public class WailsForegroundService extends android.app.Service {
|
||||
.build();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||
// MEDIA_PLAYBACK, not the scaffold's DATA_SYNC. It must match
|
||||
// android:foregroundServiceType in the manifest, or
|
||||
// startForeground throws; and on Android 14+ the declared type
|
||||
// is what decides whether the service may start from the
|
||||
// background at all.
|
||||
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, n);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,35 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* CheckStorageAccess asks the filesystem rather than the permission
|
||||
* system.
|
||||
*
|
||||
* On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
|
||||
* grants on a Settings screen rather than in a dialog — so it can be
|
||||
* refused, revoked later, or simply never answered, and the permission
|
||||
* API is one more thing that can disagree with reality. Reading the
|
||||
* directory is the question the library scanner will actually ask, so
|
||||
* it is the one worth answering.
|
||||
*
|
||||
* It is deliberately not an error return: "we cannot read your music
|
||||
* yet" is a state the UI renders, not a failure of the call.
|
||||
*/
|
||||
export function CheckStorageAccess(): $CancellablePromise<$models.StorageAccess> {
|
||||
return $Call.ByID(1661980006);
|
||||
}
|
||||
|
||||
/**
|
||||
* DefaultBrowseRoot is where a folder picker should open.
|
||||
*/
|
||||
export function DefaultBrowseRoot(): $CancellablePromise<string> {
|
||||
return $Call.ByID(497852148);
|
||||
}
|
||||
|
||||
/**
|
||||
* DirectoryPicker opens a directory selection dialog.
|
||||
*
|
||||
@@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise<string> {
|
||||
return $Call.ByID(3245034282);
|
||||
}
|
||||
|
||||
/**
|
||||
* HasNativeDirectoryPicker reports whether this platform can open a
|
||||
* directory dialog at all.
|
||||
*
|
||||
* It is asked of the backend rather than tested in the frontend with
|
||||
* `System.IsAndroid()`, for three reasons. The dialog *is* backend code
|
||||
* — `DirectoryPicker` above — so this is the same package saying what
|
||||
* it can do. It answers for iOS too without the frontend enumerating
|
||||
* platforms. And it makes the frontend's fallback testable through the
|
||||
* ordinary transport fake instead of a module mock of the Wails
|
||||
* runtime, whose platform helpers read build constants.
|
||||
*/
|
||||
export function HasNativeDirectoryPicker(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(1028901937);
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageFilePicker opens a file selection dialog filtered to image
|
||||
* files (JPEG, PNG). Returns the selected file path, or empty
|
||||
@@ -29,6 +74,30 @@ export function ImageFilePicker(): $CancellablePromise<string> {
|
||||
return $Call.ByID(3408786006);
|
||||
}
|
||||
|
||||
/**
|
||||
* ListDirectories returns the directories directly inside path, so the
|
||||
* frontend can draw a folder picker.
|
||||
*
|
||||
* **It exists because Android has no directory picker.** Wails' file
|
||||
* dialog can choose directories on every desktop platform, and on
|
||||
* Android it returns an error: the Storage Access Framework yields tree
|
||||
* URIs rather than filesystem paths, and a path is what this app's
|
||||
* entire library model is keyed on. Rather than teach the backend about
|
||||
* tree URIs, the app browses the filesystem itself — which it can do
|
||||
* because it holds all-files access (see the manifest).
|
||||
*
|
||||
* Three rules, each of which a picker gets wrong if it is not stated:
|
||||
* only directories are returned, because the caller is choosing a
|
||||
* library root and files are noise; unreadable children are skipped
|
||||
* rather than failing the whole listing, since Android's storage root
|
||||
* contains directories no app may enter; and hidden directories are
|
||||
* omitted, because a music library is not in one and `.thumbnails`
|
||||
* alone would swamp the list.
|
||||
*/
|
||||
export function ListDirectories(path: string): $CancellablePromise<$models.DirListing> {
|
||||
return $Call.ByID(692624856, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* PlaylistFilePicker opens a file selection dialog filtered
|
||||
* to M3U/M3U8 playlist files. Multiple files may be selected.
|
||||
|
||||
@@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js";
|
||||
export {
|
||||
FrontendUtil
|
||||
};
|
||||
|
||||
export type {
|
||||
DirEntry,
|
||||
DirListing,
|
||||
StorageAccess
|
||||
} from "./models.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* DirEntry is one selectable directory in a listing.
|
||||
*/
|
||||
export interface DirEntry {
|
||||
"name": string;
|
||||
"path": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DirListing is one level of the filesystem, as a folder picker needs
|
||||
* it: where we are, what is above, and the directories below.
|
||||
*
|
||||
* Parent is empty at a root, which is what tells the UI not to draw an
|
||||
* "up" affordance rather than having it compute that from the path
|
||||
* separator.
|
||||
*/
|
||||
export interface DirListing {
|
||||
"path": string;
|
||||
"parent": string;
|
||||
"entries": DirEntry[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* StorageAccess reports whether the app can actually read the place the
|
||||
* user's music lives.
|
||||
*/
|
||||
export interface StorageAccess {
|
||||
"root": string;
|
||||
"readable": boolean;
|
||||
"reason": string;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
SetQueueFallback,
|
||||
} from '@go/config/config.js';
|
||||
import { GetIndexStatus } from '@go/explore/service.js';
|
||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { describeError, explainError } from '@utils/describe-error';
|
||||
import type * as library from '@go/library/models.js';
|
||||
@@ -50,6 +49,7 @@ import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||
import { shortcutsStore } from '../../store/shortcuts-store';
|
||||
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
||||
import { list } from '@utils/binding';
|
||||
import { pickDirectory } from '../../utils/pick-directory';
|
||||
|
||||
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
||||
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
||||
@@ -916,7 +916,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
let dir = '';
|
||||
|
||||
try {
|
||||
dir = await DirectoryPicker();
|
||||
dir = (await pickDirectory()) ?? '';
|
||||
|
||||
if (!dir) return;
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
ProviderField,
|
||||
} from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
||||
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
|
||||
import { SetPreferences } from '@go/download/service.js';
|
||||
import type * as download from '@go/download/models.js';
|
||||
@@ -23,6 +22,7 @@ import { compact } from '@utils/binding';
|
||||
import { describeError, explainError } from '@utils/describe-error';
|
||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||
import './config-section';
|
||||
import { pickDirectory } from '../../utils/pick-directory';
|
||||
|
||||
/**
|
||||
* Allowed audio formats for auto-download, mirrored from
|
||||
@@ -580,7 +580,7 @@ export class DownloadClients extends LitElement {
|
||||
|
||||
private browseForFolder = async (field: ProviderField) => {
|
||||
try {
|
||||
const dir = await DirectoryPicker();
|
||||
const dir = await pickDirectory();
|
||||
|
||||
if (dir) {
|
||||
this.draft = { ...this.draft, [field.key]: dir };
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
AddLibrary,
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
} from '@go/library/library.js';
|
||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
||||
import { describeError, explainError } from '@utils/describe-error';
|
||||
import { nameDialogsIn } from '@utils/name-dialog';
|
||||
import { pickDirectory } from '../../utils/pick-directory';
|
||||
|
||||
/**
|
||||
* First-run setup wizard.
|
||||
@@ -243,7 +243,7 @@ export class FirstRunWizard extends LitElement {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
const dir = await DirectoryPicker();
|
||||
const dir = await pickDirectory();
|
||||
|
||||
if (dir) this.selectedDirectory = dir;
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Choosing a directory, where the platform will not do it for us.
|
||||
*
|
||||
* Wails' file dialog can select directories on every desktop platform.
|
||||
* On Android it returns an error, because the Storage Access Framework
|
||||
* yields tree URIs rather than filesystem paths — and a path is what
|
||||
* this app's whole library model is keyed on. So the app browses the
|
||||
* filesystem itself, through `ListDirectories`, which it can do because
|
||||
* it holds all-files access.
|
||||
*
|
||||
* Deliberately not a general file browser: it lists directories only,
|
||||
* because the thing being chosen is a library root.
|
||||
*
|
||||
* The shape is `confirm-dialog`'s — a promise-returning `choose()` on a
|
||||
* `wa-dialog`, so callers `await` a path or `null` and there is no
|
||||
* second dialog pattern in the codebase.
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, query, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
|
||||
import {
|
||||
DefaultBrowseRoot,
|
||||
ListDirectories,
|
||||
} from '@go/frontendutil/frontendutil.js';
|
||||
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { srOnly } from '../../styles/sr-only.css';
|
||||
import { describeError } from '../../utils/describe-error';
|
||||
import { nameDialogsIn } from '../../utils/name-dialog';
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@customElement('folder-picker')
|
||||
export class FolderPicker extends LitElement {
|
||||
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
|
||||
|
||||
@state() private path = '';
|
||||
@state() private parent = '';
|
||||
@state() private entries: Entry[] = [];
|
||||
@state() private loading = false;
|
||||
@state() private errorMessage = '';
|
||||
@state() private isOpen = false;
|
||||
|
||||
private settle: ((path: string | null) => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
srOnly,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
margin-bottom: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
ul {
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 4px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 45vh;
|
||||
min-height: 8em;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li button {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font: inherit;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
li button:hover,
|
||||
li button:focus-visible {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--yj-error-text, #ff8787);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.actions button.primary {
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent-fg, #000);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/** Browse. Resolves with an absolute path, or null if cancelled. */
|
||||
async choose(startAt?: string): Promise<string | null> {
|
||||
this.settle?.(null);
|
||||
this.settle = null;
|
||||
|
||||
let start = startAt ?? '';
|
||||
|
||||
if (!start) {
|
||||
try {
|
||||
start = await DefaultBrowseRoot();
|
||||
} catch {
|
||||
start = '';
|
||||
}
|
||||
}
|
||||
|
||||
this.isOpen = true;
|
||||
await this.load(start);
|
||||
await this.updateComplete;
|
||||
|
||||
if (this.dialog) this.dialog.open = true;
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
this.settle = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
private async load(path: string): Promise<void> {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
const listing = await ListDirectories(path);
|
||||
|
||||
this.path = listing.path;
|
||||
this.parent = listing.parent;
|
||||
this.entries = listing.entries ?? [];
|
||||
} catch (err) {
|
||||
// A directory that cannot be read is not a failed picker —
|
||||
// stay where we are and say so, or the user is stranded
|
||||
// with an empty dialog and no way back.
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'That folder could not be opened.',
|
||||
);
|
||||
console.error('folder-picker: listing failed:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private close(path: string | null): void {
|
||||
const settle = this.settle;
|
||||
|
||||
this.settle = null;
|
||||
|
||||
if (this.dialog) this.dialog.open = false;
|
||||
this.isOpen = false;
|
||||
settle?.(path);
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
nameDialogsIn(this.shadowRoot);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.isOpen) return nothing;
|
||||
|
||||
return html`
|
||||
<wa-dialog
|
||||
label="Choose a folder"
|
||||
data-testid="folder-picker"
|
||||
@wa-hide=${() => this.close(null)}
|
||||
>
|
||||
<p class="current" data-testid="folder-picker-path">
|
||||
${this.path || '\u2026'}
|
||||
</p>
|
||||
|
||||
${this.errorMessage
|
||||
? html`<p class="error" role="alert">
|
||||
${this.errorMessage}
|
||||
</p>`
|
||||
: nothing}
|
||||
|
||||
<ul data-testid="folder-picker-list">
|
||||
${this.parent
|
||||
? html`<li>
|
||||
<button
|
||||
@click=${() => void this.load(this.parent)}
|
||||
data-testid="folder-picker-up"
|
||||
>
|
||||
<span aria-hidden="true">\u2191</span> Up one
|
||||
level
|
||||
</button>
|
||||
</li>`
|
||||
: nothing}
|
||||
${this.entries.map(
|
||||
(entry) => html`
|
||||
<li>
|
||||
<button
|
||||
@click=${() => void this.load(entry.path)}
|
||||
>
|
||||
<span aria-hidden="true">\u{1F4C1}</span>
|
||||
${entry.name}
|
||||
</button>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
${!this.loading && this.entries.length === 0
|
||||
? html`<li class="empty">No folders here.</li>`
|
||||
: nothing}
|
||||
</ul>
|
||||
|
||||
<!--
|
||||
The live region is in the DOM before it has anything to
|
||||
say, because most screen readers announce a change to a
|
||||
region they are already watching and ignore one that
|
||||
appears with its content already in it.
|
||||
-->
|
||||
<p class="sr-only" role="status" aria-live="polite">
|
||||
${this.loading
|
||||
? 'Loading folders'
|
||||
: `${this.entries.length} folders in ${this.path}`}
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button @click=${() => this.close(null)}>Cancel</button>
|
||||
<button
|
||||
class="primary"
|
||||
?disabled=${!this.path}
|
||||
@click=${() => this.close(this.path)}
|
||||
data-testid="folder-picker-select"
|
||||
>
|
||||
Use this folder
|
||||
</button>
|
||||
</div>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'folder-picker': FolderPicker;
|
||||
}
|
||||
}
|
||||
@@ -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