summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 23:16:14 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 23:16:14 +0300
commit1c912d5feee218ef46a295c05652f2dc3bd4d1d5 (patch)
tree2358d0cffa9c58fabe162b24f6ecb0510898a41b /player-android
parentc2944be8708f4bb9c687679b4ed63e1398b83f05 (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>
Diffstat (limited to 'player-android')
-rw-r--r--player-android/android/app/src/main/AndroidManifest.xml6
-rw-r--r--player-android/android/app/src/main/kotlin/zone/foo/player_android/MainActivity.kt9
-rw-r--r--player-android/lib/api/dio_client.dart26
-rw-r--r--player-android/lib/providers/auth_state_provider.dart41
-rw-r--r--player-android/pubspec.lock24
-rw-r--r--player-android/pubspec.yaml2
6 files changed, 87 insertions, 21 deletions
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: