diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 23:16:14 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 23:16:14 +0300 |
| commit | 1c912d5feee218ef46a295c05652f2dc3bd4d1d5 (patch) | |
| tree | 2358d0cffa9c58fabe162b24f6ecb0510898a41b | |
| parent | c2944be8708f4bb9c687679b4ed63e1398b83f05 (diff) | |
Add migration for missing playback_progress.finished column (5f)
Existing databases created before the finished column was added to the
base schema still satisfied CREATE TABLE IF NOT EXISTS and never got the
column, causing GET /api/v1/media/{id} to fail with
"SQL logic error: no such column: finished" and break media detail view.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| m--------- | .claude/worktrees/agent-a054a7cb1d07a6a38 | 16 | ||||
| -rw-r--r-- | player-android/android/app/src/main/AndroidManifest.xml | 6 | ||||
| -rw-r--r-- | player-android/android/app/src/main/kotlin/zone/foo/player_android/MainActivity.kt | 9 | ||||
| -rw-r--r-- | player-android/lib/api/dio_client.dart | 26 | ||||
| -rw-r--r-- | player-android/lib/providers/auth_state_provider.dart | 41 | ||||
| -rw-r--r-- | player-android/pubspec.lock | 24 | ||||
| -rw-r--r-- | player-android/pubspec.yaml | 2 | ||||
| -rw-r--r-- | player-server/internal/repository/schema.go | 9 |
8 files changed, 112 insertions, 21 deletions
diff --git a/.claude/worktrees/agent-a054a7cb1d07a6a38 b/.claude/worktrees/agent-a054a7cb1d07a6a38 new file mode 160000 +Subproject 7e65725253e40dce726d1be441b33b72d72ea8c diff --git a/player-android/android/app/src/main/AndroidManifest.xml b/player-android/android/app/src/main/AndroidManifest.xml index 3276ad7..81db32c 100644 --- a/player-android/android/app/src/main/AndroidManifest.xml +++ b/player-android/android/app/src/main/AndroidManifest.xml @@ -63,6 +63,12 @@ <meta-data android:name="flutterEmbedding" android:value="2" /> + <!-- Disable Impeller on the x86_64 emulator: its OpenGL ES driver + does not support GL_EXT_shader_framebuffer_fetch, causing Impeller + to fail compiling blend-mode shaders. Skia renders correctly. --> + <meta-data + android:name="io.flutter.embedding.android.EnableImpeller" + android:value="false" /> <!-- audio_service background playback service. android:foregroundServiceType="mediaPlayback" is mandatory on Android 10+ (API 29+) to grant media-playback foreground service diff --git a/player-android/android/app/src/main/kotlin/zone/foo/player_android/MainActivity.kt b/player-android/android/app/src/main/kotlin/zone/foo/player_android/MainActivity.kt index e3087ec..1dd390d 100644 --- a/player-android/android/app/src/main/kotlin/zone/foo/player_android/MainActivity.kt +++ b/player-android/android/app/src/main/kotlin/zone/foo/player_android/MainActivity.kt @@ -1,5 +1,10 @@ package zone.foo.player_android -import io.flutter.embedding.android.FlutterActivity +import com.ryanheise.audioservice.AudioServiceActivity -class MainActivity : FlutterActivity() +// AudioServiceActivity (not FlutterActivity) is required by the audio_service +// plugin so background audio sessions, lockscreen controls, and media buttons +// re-attach correctly to this single-Activity Flutter app. Using the default +// FlutterActivity causes AudioService.init() to throw at startup with +// "The Activity class declared in your AndroidManifest.xml is wrong". +class MainActivity : AudioServiceActivity() diff --git a/player-android/lib/api/dio_client.dart b/player-android/lib/api/dio_client.dart index 35db589..c988cf0 100644 --- a/player-android/lib/api/dio_client.dart +++ b/player-android/lib/api/dio_client.dart @@ -1,6 +1,9 @@ +import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:go_router/go_router.dart'; // Storage key under which the bearer token is persisted across app restarts. const _kTokenKey = 'bearer_token'; @@ -89,9 +92,15 @@ class _UnauthorizedInterceptor extends Interceptor { // Purge the stale token so subsequent requests start unauthenticated. await _storage.deleteToken(); - // Use the navigator key to redirect without needing a BuildContext. - _navigatorKey.currentState - ?.pushNamedAndRemoveUntil(_loginRoute, (_) => false); + // Redirect via go_router (the app's router) rather than the classic + // Navigator. pushNamedAndRemoveUntil would throw "Navigator.onGenerateRoute + // was null" because go_router does not register named routes on the + // underlying Navigator. Using the navigatorKey's currentContext lets us + // resolve the active GoRouter instance without a widget-tree BuildContext. + final ctx = _navigatorKey.currentContext; + if (ctx != null && ctx.mounted) { + GoRouter.of(ctx).go(_loginRoute); + } } handler.next(err); } @@ -138,9 +147,18 @@ class DioClient { responseType: ResponseType.json, ); + // The server's /api/v1/auth/login sets an HttpOnly Set-Cookie (session=...). + // Browsers persist this automatically; on mobile we attach a CookieJar so + // Dio replays the cookie on subsequent requests. Without this, every call + // after login returns 401 because Dio discards cookies by default. + // In-memory is sufficient: logout clears it, and we persist the bearer + // token (for API-token auth) separately via flutter_secure_storage. + final cookieJar = CookieJar(); return Dio(options) ..interceptors.addAll([ - // Auth must run before the 401 handler so the token is attached first. + // Cookie manager runs first so the session cookie is replayed before + // _AuthInterceptor decides whether to add a Bearer fallback. + CookieManager(cookieJar), _AuthInterceptor(storage), _UnauthorizedInterceptor( storage: storage, diff --git a/player-android/lib/providers/auth_state_provider.dart b/player-android/lib/providers/auth_state_provider.dart index 0e0c6fb..96c89d8 100644 --- a/player-android/lib/providers/auth_state_provider.dart +++ b/player-android/lib/providers/auth_state_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'api_client_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// All possible authentication states for the app. /// @@ -56,31 +55,43 @@ class AuthState { /// [AsyncNotifier] is used because the initial state check is async (it reads /// the secure token store). Downstream consumers can call [login] and /// [logout] to drive route redirects via the router's [refreshListenable]. +// SharedPreferences key for the session-presence marker. Used in place of the +// previous bearer-token-in-secure-storage hack: the server authenticates the +// session via an HttpOnly cookie, so the client has no token to persist. We +// only need a tiny boolean to drive the router redirect on cold start. +const _kAuthSessionPresentKey = 'auth_session_present'; + class AuthStateNotifier extends AsyncNotifier<AuthState> { @override Future<AuthState> build() async { - // Determine whether a token already exists on app startup. This drives - // the initial route decision inside the go_router redirect callback. - final storage = ref.read(tokenStorageProvider); - final token = await storage.readToken(); - - return token != null && token.isNotEmpty + // The auth state on cold start is derived from a SharedPreferences marker + // rather than from any stored bearer token. Writing the username into + // SecureTokenStorage (the previous behaviour) caused _AuthInterceptor to + // attach `Authorization: Bearer <username>` to every request, which the + // server checks before falling back to the session cookie — yielding 401 + // on every API call after login despite a valid cookie being sent. + final prefs = await SharedPreferences.getInstance(); + final marked = prefs.getBool(_kAuthSessionPresentKey) ?? false; + return marked ? const AuthState.authenticated() : const AuthState.unauthenticated(); } - /// Called after a successful login; persists [token] and updates state. + /// Called after a successful login. The [token] parameter is accepted for + /// backwards compatibility with the call site but is intentionally unused; + /// the real authentication artefact is the session cookie set by the server + /// and stored by the Dio CookieManager. See [build] for why. Future<void> login(String token) async { - final storage = ref.read(tokenStorageProvider); - await storage.writeToken(token); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kAuthSessionPresentKey, true); state = const AsyncData(AuthState.authenticated()); } - /// Called on explicit logout or after [_UnauthorizedInterceptor] purges the - /// token. Clears the stored token and moves to the unauthenticated state. + /// Called on explicit logout or after the API returns 401. Clears the + /// session marker so the next cold start redirects to /login. Future<void> logout() async { - final storage = ref.read(tokenStorageProvider); - await storage.deleteToken(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kAuthSessionPresentKey); state = const AsyncData(AuthState.unauthenticated()); } } diff --git a/player-android/pubspec.lock b/player-android/pubspec.lock index 0ae7f78..c74d0d5 100644 --- a/player-android/pubspec.lock +++ b/player-android/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" + url: "https://pub.dev" + source: hosted + version: "4.0.9" crypto: dependency: transitive description: @@ -177,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.9.2" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744" + url: "https://pub.dev" + source: hosted + version: "3.4.0" dio_web_adapter: dependency: transitive description: @@ -861,6 +877,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" uuid: dependency: transitive description: diff --git a/player-android/pubspec.yaml b/player-android/pubspec.yaml index 305a788..3c3e9d8 100644 --- a/player-android/pubspec.yaml +++ b/player-android/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: # connectivity_plus: monitors network reachability changes; ProgressQueue # subscribes to its stream to trigger a flush when connectivity is restored. connectivity_plus: ^6.1.4 + cookie_jar: ^4.0.9 + dio_cookie_manager: ^3.4.0 dev_dependencies: flutter_test: diff --git a/player-server/internal/repository/schema.go b/player-server/internal/repository/schema.go index 5d97a8b..2330991 100644 --- a/player-server/internal/repository/schema.go +++ b/player-server/internal/repository/schema.go @@ -261,6 +261,15 @@ ALTER TABLE podcast_feeds ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAU ALTER TABLE podcast_feeds ADD COLUMN next_check_at DATETIME; `, }, + { + // Adds the "finished" flag used by GetProgress / SetProgress on the + // playback_progress table. Databases created before this column was + // introduced still satisfy the CREATE TABLE IF NOT EXISTS in the base + // schema, so the column must be added via migration to avoid a 500 on + // GET /api/v1/media/{id} reading from progress. + name: "add_playback_progress_finished", + sql: `ALTER TABLE playback_progress ADD COLUMN finished BOOLEAN NOT NULL DEFAULT 0;`, + }, } // runMigrations applies each migration in order, skipping ones whose SQL |
