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