Three of plan 016's four blockers. Each is a different reason the app could not work at all on a phone. **It had no permission to read anything.** The generated manifest asked for INTERNET, VIBRATE, biometrics, location and a camera, and nothing whatever about storage -- so at targetSdk 35 the app could see its own private directory and no music. It now declares READ_MEDIA_AUDIO, the two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE. That last one is deliberate and is the load-bearing choice. This app is a library manager: audio_files.file_path is the primary key of ownership, the scanner walks a directory the user chose, and tagwriter rewrites files in place. MediaStore offers no stable directory to walk and no in-place write, so scoped storage is not "more work" here, it is a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted, which is acceptable only because this ships as an APK through the package registry -- if it ever targets Play, that line is what has to go, and plan 016 says what replaces it. It is granted on a Settings screen rather than in a dialog, so it cannot be requested with requestPermissions(). MainActivity opens that screen on every cold start until access exists -- there is no degraded mode worth offering -- and re-checks in onResume, because the way back from another task is a resume, emitting android:storageAccess so the frontend can react. **The first-run flow could not complete.** All three call sites asked for a folder through the Wails dialog, which returns an error on Android: SAF yields tree URIs and this app is keyed on paths. So the app browses the filesystem itself, which it can now do. ListDirectories lists directories only (the thing being chosen is a library root), skips what it cannot stat rather than failing the listing (Android's storage root holds directories no app may enter), follows symlinks (os.DirEntry reports the link, so a symlinked music folder would silently vanish), and hides dotted entries. utils/pick-directory.ts is the one place that chooses between the two, so the three call sites changed by one line each. **Which platform is asked of the backend**, not of System.IsAndroid(): the dialog is backend code, so the backend is what knows whether it can open one; it answers for iOS at the same time; and it keeps the fallback testable through the ordinary transport fake rather than a module mock of the Wails runtime, whose platform helpers read build constants. **And MPRIS was compiled into the Android build**, because android implies the linux build tag, so it went looking for a session bus that does not exist. mpris_linux.go is `linux && !android` now and the stub covers Android, which means no lock-screen transport there yet -- a missing feature rather than a broken one, and the remaining blocker. The foreground service is typed mediaPlayback rather than the scaffold's dataSync, with the matching permission, so playback can survive the screen locking once there is a MediaSession to drive it. The type in the manifest and the one passed to startForeground must agree or startForeground throws.
182 lines
5.6 KiB
Go
182 lines
5.6 KiB
Go
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
|
|
}
|
|
}
|