diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..fd9b3fe
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+ko_fi: sphildreth
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 13fc83e..461b06f 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -15,11 +15,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Set up JDK 17
+ - name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
- java-version: '17'
+ java-version: '21'
- name: Cache Gradle
uses: actions/cache@v4
@@ -34,6 +34,9 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
+ - name: Install Android SDK packages
+ run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "platform-tools"
+
- name: Grant execute permission for gradlew
working-directory: src
run: chmod +x gradlew
@@ -44,12 +47,12 @@ jobs:
- name: Run Unit Tests
working-directory: src
- run: ./gradlew testDebugUnitTest --stacktrace
+ run: ./gradlew :app:testDebugUnitTest --stacktrace
- name: Run Instrumented Tests (emulator)
if: false # enable when emulator is set up
working-directory: src
- run: ./gradlew connectedDebugAndroidTest --stacktrace
+ run: ./gradlew :app:connectedDebugAndroidTest --stacktrace
- name: Benchmark (connected)
if: false # enable when device/emulator available
@@ -58,5 +61,4 @@ jobs:
- name: Coverage Report
working-directory: src
- run: ./gradlew jacocoTestReport --stacktrace
-
+ run: ./gradlew :app:jacocoTestReport --stacktrace
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ec187c1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,118 @@
+# Coding Agent Instructions
+
+These instructions apply to coding agents working in this repository.
+
+## Project Shape
+
+- The Git repository root is `melodee-player/`, but the Android Gradle project
+ root is `melodee-player/src/`.
+- Run Gradle from `src/`, not from the repository root.
+- The app module is `src/app`; the macrobenchmark module is `src/benchmark`.
+- The app targets Melodee API v1 and Android SDK 36, with minSdk 23.
+- The local Android SDK path used in this workspace is
+ `/home/steven/Android/Sdk`.
+
+## Change Discipline
+
+- Keep edits scoped to the requested behavior and existing architecture.
+- Do not revert user changes or unrelated workspace changes.
+- Prefer the existing Kotlin, Compose, Retrofit, Media3, coroutine, and
+ SharedPreferences patterns before introducing new frameworks.
+- Treat authentication, Android Auto, media playback, API models, and Gradle
+ configuration as high-risk areas. Add or update tests when touching them.
+- Do not add local app-data SQLite/Room persistence unless explicitly requested.
+ Media3 already uses its own SQLite-backed cache metadata internally.
+
+## Changelog
+
+- Update `CHANGELOG.md` for every notable change before finishing work.
+- Add new entries under `## [Unreleased]` by default. If the user says the
+ current version has not been released yet, or the task is explicitly part of
+ an in-progress release section such as `## [1.8.0]`, update that active
+ version section instead.
+- Follow the existing Keep a Changelog categories: `Added`, `Changed`,
+ `Deprecated`, `Removed`, `Fixed`, and `Security`.
+- Include user-visible behavior changes, API compatibility changes,
+ authentication/session changes, Android Auto changes, dependency/toolchain
+ changes, security hardening, and important test coverage additions.
+- Do not add changelog entries for purely mechanical formatting changes unless
+ they materially affect users, maintainers, CI, or release behavior.
+- Keep entries concise and factual. Mention the affected subsystem when useful.
+- If a task intentionally skips a changelog update, state why in the final
+ response.
+
+## README And Docs
+
+- Keep `README.md` accurate when setup, build commands, API behavior,
+ authentication behavior, Android Auto behavior, or project layout changes.
+- Some files under `docs/` are historical review/planning artifacts. Do not
+ treat them as more authoritative than current source, Gradle files,
+ `README.md`, and `CHANGELOG.md`.
+- If docs contradict source code, verify against source before editing.
+
+## Authentication And API
+
+- Preserve refresh tokens when API responses omit rotated refresh-token values.
+- Android Auto must be able to restore existing stored authentication without
+ requiring the user to open the handheld UI again.
+- Invalid/rejected refresh tokens may clear authentication; transient refresh
+ failures should keep stored credentials available for retry.
+- API contract changes should be covered by tests under
+ `src/app/src/test/java/com/melodee/autoplayer/api` or related model tests.
+- The current OpenAPI reference used during this work was
+ `/home/steven/Downloads/v1.json`; verify with the user before assuming that
+ path is still current in future sessions.
+
+## Android Auto And Playback
+
+- `MusicService` is exported for Android Auto discovery; caller validation must
+ remain strict before returning browse content.
+- Do not trust Android Auto callers by substring package-name checks. Use exact
+ trusted packages and package/UID ownership checks.
+- UI playback state should be driven by service/manager flows where possible,
+ not by UI-owned polling loops.
+- When changing playback state, verify handheld UI and Android Auto behavior:
+ current song, play/pause, queue, metadata, notification controls, and auth
+ restoration.
+
+## Validation
+
+- Always run:
+
+ ```bash
+ git diff --check
+ ```
+
+- For Markdown-only changes, `git diff --check` is usually enough.
+- For test-only changes, run the targeted test class and preferably the full
+ unit suite:
+
+ ```bash
+ cd src
+ ANDROID_HOME=/home/steven/Android/Sdk ./gradlew testDebugUnitTest --stacktrace
+ ```
+
+- For production Android code, build logic, shared models, authentication,
+ Android Auto, networking, or playback changes, run the full build:
+
+ ```bash
+ cd src
+ ANDROID_HOME=/home/steven/Android/Sdk ./gradlew build --stacktrace
+ ```
+
+- Connected tests and benchmarks require a device or emulator:
+
+ ```bash
+ cd src
+ ANDROID_HOME=/home/steven/Android/Sdk ./gradlew connectedDebugAndroidTest
+ ANDROID_HOME=/home/steven/Android/Sdk ./gradlew :benchmark:connectedCheck
+ ```
+
+- If a validation command cannot be run, explain why and report what was run
+ instead.
+
+## Final Response Expectations
+
+- Summarize what changed, what was verified, and any residual risk.
+- Mention skipped validations explicitly.
+- Keep file references precise for important source or documentation changes.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0015727
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,115 @@
+# Changelog
+
+This file records notable project changes. It follows the
+[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format and uses
+[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.8.0] - 2026-06-17
+
+### Added
+
+- Added encrypted SharedPreferences storage for authentication tokens, including
+ migration of existing access-token, refresh-token, and refresh-token-expiry
+ values out of the regular settings file.
+- Added refresh-token expiry persistence so refreshed authentication state can
+ be saved alongside rotated access and refresh tokens.
+- Added an OkHttp `Authenticator`-based token refresh flow that retries 401
+ responses with a refreshed access token and distinguishes invalid credentials
+ from transient network or server failures.
+- Added a separate unauthenticated Retrofit/OkHttp client for refresh-token
+ requests to avoid recursive authorization handling.
+- Added service-side playlist and search queue APIs so bound UI ViewModels can
+ hand queues directly to `MusicService` without large Parcelable intent
+ payloads.
+- Added playlist page-size tracking, in-flight next-page fetch reuse, and
+ proactive page prefetching when playback nears the end of the current queue.
+- Added MediaBrowser caller validation for the exported Android Auto media
+ service before exposing browse content.
+- Added debug-symbol keep rules for native libraries used by AndroidX graphics,
+ benchmark, DataStore, and tracing dependencies.
+- Added focused authentication persistence tests for omitted refresh-token
+ responses, refresh-token expiry retention, transient refresh failures, invalid
+ refresh failures, and Android Auto service-side authentication restoration.
+- Added OpenAPI v1 contract tests covering authentication, refresh-token
+ requests, playlist and favorite route placeholders, scrobble payloads and
+ error responses, nullable refresh-token fields, and artist genre arrays.
+- Added repository instructions for coding agents requiring changelog updates
+ for notable code, behavior, dependency, security, and documentation changes.
+- Added project-specific coding-agent guidance for nested Gradle usage,
+ validation, authentication, API contracts, Android Auto, playback, and
+ documentation expectations.
+
+### Changed
+
+- Updated the Android project version from `1.7.3` to `1.8.0`.
+- Raised the Android platform requirements from min SDK 21 / target SDK 35 to
+ min SDK 23 / target SDK 36.
+- Updated the build toolchain to Gradle 9.5.1, Android Gradle Plugin 9.2.1, and
+ JDK 21 in CI.
+- Updated Kotlin plugin usage for Kotlin 2.3.21 and Compose compiler plugin
+ integration.
+- Updated major Android and Kotlin dependencies, including Compose BOM
+ 2026.05.01, Media3 1.10.1, Retrofit 3.0.0, OkHttp 5.3.2, Coroutines 1.11.0,
+ Lifecycle 2.10.0, AndroidX media 1.8.0, Firebase BOM 34.13.0, and current
+ test libraries.
+- Reduced HTTP logging in app and image-loading clients, redacted authorization
+ headers, and disabled verbose request logging for non-debuggable builds.
+- Changed authentication failure handling so only non-recoverable refresh
+ failures clear stored credentials, while transient refresh failures leave
+ credentials available for later retries.
+- Changed single-song, playlist, search-result, and album playback setup to use
+ service methods when the playback service is already bound.
+- Changed playlist playback intents to send playlist references and start
+ metadata instead of serializing full song lists through intent extras.
+- Aligned the Retrofit v1 API contract with the current OpenAPI spec, including
+ refresh-token authentication, playlist pagination query names, album-song
+ browsing, scrobble requests, and v1 error payload parsing.
+- Updated README, documentation, review notes, and prompt artifacts for the
+ SDK 36, JDK 21, Gradle 9.5.1, and dependency-version changes.
+- Updated the benchmark module to compile and target SDK 36 with the same JDK
+ 21 toolchain configuration as the app module.
+- Updated `HomeViewModel` and `PlaylistViewModel` to collect playback state,
+ current song, playback context, duration, and position from service-backed
+ flows instead of running UI-owned polling loops.
+- Updated `MusicService` and `MusicPlaybackManager` to expose and maintain
+ flow-backed playback progress for bound UI consumers.
+- Updated the README to document current setup, API v1 authentication behavior,
+ Android Auto behavior, testing commands, and repository layout.
+- Cleaned Android Studio commit-inspection warnings in playback, playlist,
+ home, service, and model serialization code by removing dead helpers,
+ simplifying redundant conditions, and keeping only intentional Android
+ lifecycle suppressions.
+- Updated Android CI to explicitly install Android SDK 36 and run app-qualified
+ unit-test and JaCoCo tasks from the nested Gradle project.
+
+### Fixed
+
+- Fixed token value leakage in logs by logging token presence instead of token
+ prefixes.
+- Fixed refresh-token rotation persistence by saving new refresh tokens and
+ refresh-token expiry values from network refresh callbacks.
+- Fixed Android Auto playlist continuation to avoid starting duplicate next-page
+ fetches when playback reaches a queue boundary during an active prefetch.
+- Fixed queue navigation state updates by separating next/previous queue
+ movement from immediate playback commands in the consolidated playback
+ manager.
+- Fixed logout flow to route through `AuthenticationManager` when available so
+ stored settings, network authentication state, and UI authentication state stay
+ synchronized.
+- Fixed nullable refresh-token response handling so login and token refresh
+ persistence tolerate v1 responses that omit rotated refresh-token values.
+- Fixed the JaCoCo report task so CI coverage uses debug unit-test execution
+ data and non-instrumented app classes instead of requiring connected Android
+ coverage.
+
+### Security
+
+- Moved sensitive authentication tokens from regular SharedPreferences to
+ encrypted SharedPreferences when the platform keystore-backed implementation
+ is available.
+- Reduced sensitive network logging by redacting authorization headers and
+ disabling OkHttp body logging.
+- Kept the media browser service exported for Android Auto discovery while
+ adding caller checks before returning a browser root.
+- Tightened Android Auto media browser caller classification by replacing
+ package-name substring trust with exact trusted package matching.
diff --git a/README.md b/README.md
index 17a0649..6b33c44 100644
--- a/README.md
+++ b/README.md
@@ -2,110 +2,177 @@
# Melodee Android Auto Player
- Native Android + Android Auto client for the Melodee self-hosted music server, built with Jetpack Compose and Media3.
+ Native Android and Android Auto client for the Melodee self-hosted music server, built with Kotlin, Jetpack Compose, and Media3.
[](https://github.com/melodee-project/melodee-player/actions/workflows/android.yml)
[](LICENSE)
- 
- 
+ 
+ 
- Features | Quick Start | Android Auto | Architecture | Testing | Documentation | Contributing
+ [Features](#features) | [Quick Start](#quick-start) | [Configuration](#configuration) | [Android Auto](#android-auto) | [Architecture](#architecture--stack) | [Testing](#testing)
## Overview
-Melodee Player is a Kotlin/Compose app (version 1.7.1, minSdk 21 / targetSdk 35) that streams music from a Melodee server to phones and Android Auto. It ships with a background MediaBrowserService for car interfaces, a Compose UI for handheld use, and a Media3-based playback stack with caching and scrobbling.
+Melodee Player is a Kotlin/Compose Android app for browsing, searching, and streaming music from a Melodee API v1 server. Version 1.8.0 targets Android SDK 36, supports Android 6.0+ devices (minSdk 23), and includes a `MediaBrowserServiceCompat`/`MediaSessionCompat` playback service for Android Auto.
+
+The Gradle project is intentionally nested under `src/`. Open `src/` in Android Studio or run Gradle from that directory.
## Features
-- Android Auto ready: MediaBrowserServiceCompat + MediaSession with browse/search, metadata, steering wheel controls, and voice-triggered playback.
-- Rich browse/search: playlists, songs, artists, and albums with pagination, infinite scroll, artist/album drill-ins, and server-side favorites.
-- Playback service: Media3/ExoPlayer with queue control, shuffle/repeat, prefetching, notification controls, and streaming cache.
-- Resilient networking: login and token refresh against a Melodee API, request deduplication, retry/backoff, normalized server URLs, and scrobble reporting.
-- Compose UI: Material 3 theming (light/dark/dynamic), now playing screen + mini player, playlist view, and artist/album galleries with Coil-powered artwork.
-- Performance and diagnostics: macrobenchmarks, unit/UI tests, optional performance monitor hooks, and detailed logging for Android Auto interactions.
+- Android Auto browsing, search, voice-triggered playback, media buttons, metadata, and notification controls.
+- Compose handheld UI with login, home/search, playlist, now-playing, settings, theme, artist, and album views.
+- Media3/ExoPlayer playback with queue control, shuffle/repeat, seeking, foreground playback notification, audio focus handling, prefetching, and on-disk media caching.
+- Melodee API v1 integration for authentication, refresh-token renewal, current user profile, user playlists, playlist songs, search, artist browsing, album songs, favorites, and scrobbling.
+- Authentication persistence with encrypted SharedPreferences when available, migration from previous plaintext token keys, refresh-token retention, and Android Auto service-side restoration.
+- OkHttp/Retrofit networking with bearer-token injection, automatic 401 refresh, retry/backoff for recoverable failures, HTTP caching, authorization-header redaction, and request deduplication.
+- Local unit and Robolectric tests, a macrobenchmark module, JaCoCo coverage wiring, and GitHub Actions CI for build/test/coverage.
+
+## Requirements
+
+- Android Studio that supports Android Gradle Plugin 9.2.1.
+- JDK 21. The Gradle daemon toolchain is configured for Java 21.
+- Android SDK Platform 36 and Build Tools installed through Android Studio.
+- A device or emulator running API 23 or newer.
+- A running Melodee API v1 server. This repository does not include the server source.
## Quick Start
-1. **Requirements**
- - Android Studio Hedgehog (2023.1.1)+, JDK 17, Android SDK Platform 35
- - Device or emulator on API 21+ (Android Auto features require a device or emulator with Android Auto)
- - A running Melodee API server (use your deployment or the bundled `api-server/`—see its README for setup)
-2. **Clone and open**
+1. Clone the repository.
+
```bash
git clone https://github.com/melodee-project/melodee-player.git
cd melodee-player/src
```
- Open the `src` directory in Android Studio or keep using the CLI.
-3. **Build and run**
+
+2. Open the project in Android Studio.
+
+ Use **File > Open** and select `melodee-player/src`, not the repository root.
+
+3. Create or select an Android run configuration.
+
+ Use an **Android App** configuration with module `app`, deploy target set to your emulator or connected device, and launch activity set to the default launcher activity.
+
+4. Build from the command line when needed.
+
```bash
- ./gradlew build # compile and run unit tests
- ./gradlew installDebug # deploy to a connected device/emulator
+ ./gradlew build
+ ./gradlew installDebug
```
-4. **Configure the backend**
- - Launch the app, enter your Melodee server base URL (e.g., `https://your-host/`), and sign in with your account.
- - The app stores auth tokens locally and will refresh them automatically during playback or search.
-5. **Use it**
- - Browse playlists/artists/albums, search, start playback, and manage the queue (shuffle/repeat, add/remove, skip/seek).
- - Connect to Android Auto to browse, search, and control playback hands-free.
+
+5. Launch Melodee and sign in with your server URL, email or username, and password.
+
+## Configuration
+
+### Server URL
+
+Enter the base URL for your Melodee server in the login screen, for example:
+
+```text
+https://music.example.com/
+```
+
+The app normalizes the base URL and then calls versioned API routes such as `/api/v1/auth/authenticate` and `/api/v1/auth/refresh-token`.
+
+Remote cleartext HTTP is disabled. For local development, cleartext traffic is allowed only for `localhost`, `127.0.0.1`, `10.0.2.2`, and `10.0.3.2`.
+
+When testing from the standard Android emulator against a server running on this computer, use:
+
+```text
+http://10.0.2.2:/
+```
+
+### Authentication
+
+Access and refresh tokens are stored locally. On devices where AndroidX Security encrypted preferences are available, token values are stored in encrypted SharedPreferences. If encrypted preferences cannot be created, the app falls back to regular app-private SharedPreferences.
+
+The refresh token is preserved when the server returns a new access token without rotating the refresh token. Android Auto uses the same stored authentication state as the handheld app, so plugging into a car should not require another login unless the server rejects or expires the refresh token.
+
+### Firebase
+
+The app module includes Firebase Crashlytics and Analytics dependencies and a checked-in `google-services.json`. Replace that file if you build under a different Firebase project.
## Android Auto
-- Browse playlists and the current queue from Android Auto using the MediaBrowser tree.
-- Voice search and playback via Google Assistant; results are cached for quick follow-up commands.
-- Full metadata (art, artist, album) and notification controls; steering wheel buttons map to previous/next/play-pause.
-- Queue mutations (add/clear) stay in sync with the handheld UI.
+The app declares an exported media browser service for Android Auto discovery. `MusicService.onGetRoot()` validates the caller before returning browse content.
+
+Current Android Auto capabilities include:
+
+- Playlist and current-queue browsing through the media browser tree.
+- Search and voice-triggered playback through media search intents.
+- Play, pause, previous, next, seek, shuffle, repeat, and favorite actions where supported by the active media surface.
+- Playback metadata, artwork, media buttons, and foreground notification controls.
+- Authentication restoration from local storage when Android Auto starts the service before the handheld UI is opened.
+
+For desktop testing, run the app on an emulator or connected device from Android Studio, sign in once through the handheld UI, then use Android Auto Desktop Head Unit or an Android Auto-capable test environment to connect to the same installed app.
## Architecture & Stack
-### Project layout
-- `src/app` — Compose Android app (app module)
-- `src/benchmark` — Macrobenchmark suite for startup/navigation/scroll performance
-- `docs/` — Architecture, reviews, Android Auto specs, performance notes
-- `api-server/` — Melodee backend source for local testing (see `api-server/README.md`)
-- `graphics/` — Branding assets for docs/UI
-- `prompts/` — Prompt artifacts used during development
-
-### Tech stack
-- UI: Jetpack Compose, Material 3, Navigation Compose
-- Playback: Media3/ExoPlayer, MediaBrowserServiceCompat, MediaSessionCompat, Media cache/prefetch
-- Networking: Retrofit + OkHttp (logging, retry/backoff, caching), Gson, request deduplication, token refresh handling
-- Data/auth: SharedPreferences-based SettingsManager (encrypted SecureSettingsManager available for migration), URL normalization, scrobble API integration
-- Concurrency: Kotlin Coroutines + Flow
-- Images: Coil (+ GIF support)
-- Testing: JUnit, MockK, Truth, Robolectric, Compose UI tests, AndroidX Test, Macrobenchmark, JaCoCo coverage
+### Project Layout
+
+- `src/app` - Android application module.
+- `src/benchmark` - Macrobenchmark module targeting `:app`.
+- `docs/` - Design notes, review artifacts, API migration notes, and Android Auto planning docs.
+- `graphics/` - Branding assets used by repository documentation.
+- `prompts/` - Development prompt artifacts.
+- `CHANGELOG.md` - Release notes for this repository.
+
+### App Stack
+
+- UI: Jetpack Compose, Material 3, Navigation Compose.
+- Playback: Media3/ExoPlayer, `MediaBrowserServiceCompat`, `MediaSessionCompat`, Android media notifications.
+- Networking: Retrofit 3, OkHttp 5, Gson, retry/backoff, HTTP cache, request deduplication.
+- Auth/data: `SettingsManager`, encrypted SharedPreferences, refresh-token persistence, URL normalization.
+- Images: Coil with memory and disk caching.
+- Background work: Kotlin coroutines and Flow.
+- Tests: JUnit 4, MockK, Truth, Robolectric, MockWebServer, AndroidX Test, Macrobenchmark, JaCoCo.
## Testing
-From `src/`:
+Run these commands from `src/`.
```bash
-# Unit tests
+# Local unit and Robolectric tests
./gradlew testDebugUnitTest
-# Instrumented/UI tests (requires emulator/device)
+# Full local build, lint, unit tests, app APKs, and benchmark module build
+./gradlew build
+
+# Connected app instrumentation tests, when a device/emulator is available
./gradlew connectedDebugAndroidTest
-# Macrobenchmarks (requires emulator/device)
+# Connected macrobenchmarks, when a device/emulator is available
./gradlew :benchmark:connectedCheck
-# Coverage report
+# JaCoCo coverage report
./gradlew jacocoTestReport
```
-CI runs `./gradlew build` and `./gradlew testDebugUnitTest` via GitHub Actions (`android.yml`).
+If the Android SDK is not discovered automatically, set `ANDROID_HOME` before running Gradle:
+
+```bash
+ANDROID_HOME=/path/to/Android/Sdk ./gradlew testDebugUnitTest
+```
+
+GitHub Actions installs Android SDK 36 and currently runs `./gradlew build --stacktrace`, `./gradlew :app:testDebugUnitTest --stacktrace`, and `./gradlew :app:jacocoTestReport --stacktrace`. Connected tests and benchmarks are present in the workflow but disabled until CI has an emulator or device runner.
## Documentation
-- Android Auto implementation notes: `docs/README.md`
-- Code and performance reviews: `docs/REVIEW-SUMMARY.md`, `docs/performance_review.md`, `docs/PERFORMANCE_ANALYSIS.md`
-- Implementation and migration details: `docs/implementation-summary.md`, `docs/MIGRATION-SUMMARY.md`
+- Current release notes: `CHANGELOG.md`
+- API v1 migration and reviews: `docs/API-CHANGES.md`, `docs/API-CHANGES-REVIEW.md`, `docs/API-CHANGES-FINAL-REVIEW.md`
+- Android Auto design and planning notes: `docs/ANDROID_AUTO_TECHNICAL_SPEC.md`, `docs/ANDROID_AUTO_ENHANCEMENT_CHECKLIST.md`
+- Performance and review notes: `docs/performance_review.md`, `docs/PERFORMANCE_ANALYSIS.md`, `docs/REVIEW-SUMMARY.md`
+- Test planning notes: `docs/test_map.md`, `docs/test_commands.md`
+
+Some files under `docs/` are historical review or planning artifacts. Prefer this README, `CHANGELOG.md`, and the Gradle build files as the current source of truth for setup and supported commands.
## Contributing
-1. Fork and create a feature branch (`git checkout -b feature/my-change`).
-2. Make changes, add tests when possible, and run the test suite.
-3. Open a PR describing the change and any user-facing impact (screenshots for UI tweaks help).
+1. Create a feature branch.
+2. Keep changes focused and add or update tests when behavior changes.
+3. Run `./gradlew testDebugUnitTest` for focused changes and `./gradlew build` before opening a PR.
+4. Include screenshots or screen recordings for visible UI changes.
+5. Describe any Android Auto, authentication, or API compatibility impact in the PR.
## License
diff --git a/docs/API-CHANGES-REVIEW.md b/docs/API-CHANGES-REVIEW.md
index e16f656..4f5ed0a 100644
--- a/docs/API-CHANGES-REVIEW.md
+++ b/docs/API-CHANGES-REVIEW.md
@@ -1276,7 +1276,7 @@ class RetrofitPathTest {
**Dependencies Required:**
Add to `src/app/build.gradle.kts`:
```kotlin
-testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
```
---
diff --git a/docs/CODE-REVIEW-2025-12-27.md b/docs/CODE-REVIEW-2025-12-27.md
index 6966357..9e41eb1 100644
--- a/docs/CODE-REVIEW-2025-12-27.md
+++ b/docs/CODE-REVIEW-2025-12-27.md
@@ -286,14 +286,14 @@ sealed class AppError {
## Dependencies Review
### Core Dependencies ✅
-- **Compose**: Up to date (BOM 2024.12.01)
-- **Media3**: Using 1.2.0 (current stable is 1.4.1 - consider upgrading)
-- **Kotlin**: 1.9.20 (current stable is 1.9.22 - minor update available)
-- **Retrofit**: 2.11.0 ✅
-- **Coroutines**: 1.9.0 ✅
+- **Compose**: Up to date (BOM 2026.05.01)
+- **Media3**: 1.10.1 ✅
+- **Kotlin**: 2.3.21 with AGP built-in Kotlin ✅
+- **Retrofit**: 3.0.0 ✅
+- **Coroutines**: 1.11.0 ✅
### Security Dependencies ✅
-- **EncryptedSharedPreferences**: 1.1.0-alpha06 (appropriate for production)
+- **EncryptedSharedPreferences**: 1.1.0
### Removed Dependencies ✅
- ~~Room database~~ (no longer needed)
diff --git a/docs/REVIEW-SUMMARY.md b/docs/REVIEW-SUMMARY.md
index 0195989..193ae25 100644
--- a/docs/REVIEW-SUMMARY.md
+++ b/docs/REVIEW-SUMMARY.md
@@ -2,7 +2,7 @@
**Date**: 2025-12-27
**Project**: Melodee Android Auto Player
-**Version**: 1.7.1 → 1.8.0 (Proposed)
+**Version**: 1.8.0
**Review Type**: Performance, Persistence, and Usability Analysis
---
diff --git a/docs/code-review-findings.md b/docs/code-review-findings.md
index 369cec7..0bd8df9 100644
--- a/docs/code-review-findings.md
+++ b/docs/code-review-findings.md
@@ -3,7 +3,7 @@
**Date**: 2025-12-27
**Reviewer**: GitHub Copilot CLI
**Project**: Melodee Android Auto Player
-**Version**: 1.7.1
+**Version**: 1.8.0
## Executive Summary
diff --git a/docs/fresh-review-post-implementation.md b/docs/fresh-review-post-implementation.md
index 31f8e55..2154365 100644
--- a/docs/fresh-review-post-implementation.md
+++ b/docs/fresh-review-post-implementation.md
@@ -174,18 +174,18 @@ if (BuildConfig.DEBUG) {
**Changes**:
```kotlin
// Added KSP plugin for Room
-id("com.google.devtools.ksp") version "1.9.20-1.0.14"
+id("com.google.devtools.ksp") version "2.3.8"
// Added dependencies
-val roomVersion = "2.6.1"
+val roomVersion = "2.8.4"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
-implementation("androidx.security:security-crypto:1.1.0-alpha06")
+implementation("androidx.security:security-crypto:1.1.0")
```
**Quality Aspects**:
-- Correct versions (Room 2.6.1 is latest stable)
+- Current stable Room version
- KSP instead of kapt (better performance)
- Organized with version variable
- Security library is alpha, but stable enough for production
diff --git a/docs/implementation-guide.md b/docs/implementation-guide.md
index 51859bb..23215bc 100644
--- a/docs/implementation-guide.md
+++ b/docs/implementation-guide.md
@@ -99,7 +99,7 @@ Update `app/build.gradle.kts`:
```kotlin
dependencies {
- val roomVersion = "2.6.1"
+ val roomVersion = "2.8.4"
// Room
implementation("androidx.room:room-runtime:$roomVersion")
@@ -107,8 +107,8 @@ dependencies {
ksp("androidx.room:room-compiler:$roomVersion")
// Paging
- implementation("androidx.paging:paging-runtime-ktx:3.2.1")
- implementation("androidx.paging:paging-compose:3.2.1")
+ implementation("androidx.paging:paging-runtime-ktx:3.5.0")
+ implementation("androidx.paging:paging-compose:3.5.0")
implementation("androidx.room:room-paging:$roomVersion")
}
```
@@ -118,9 +118,8 @@ Also add KSP plugin to `plugins` block:
```kotlin
plugins {
id("com.android.application")
- id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
- id("com.google.devtools.ksp") version "1.9.20-1.0.14"
+ id("com.google.devtools.ksp") version "2.3.8"
id("jacoco")
}
```
diff --git a/docs/implementation-summary.md b/docs/implementation-summary.md
index 1820a32..4f2d53c 100644
--- a/docs/implementation-summary.md
+++ b/docs/implementation-summary.md
@@ -1,7 +1,7 @@
# Implementation Summary - Performance & Persistence Improvements
**Date**: 2025-12-27
-**Version**: 1.7.1 → 1.8.0
+**Version**: 1.8.0
**Changes**: Performance optimizations, persistence layer, security improvements
---
@@ -148,17 +148,17 @@ Standard SharedPreferences
**New Dependencies**:
```kotlin
// Room Database
-implementation("androidx.room:room-runtime:2.6.1")
-implementation("androidx.room:room-ktx:2.6.1")
-ksp("androidx.room:room-compiler:2.6.1")
+implementation("androidx.room:room-runtime:2.8.4")
+implementation("androidx.room:room-ktx:2.8.4")
+ksp("androidx.room:room-compiler:2.8.4")
// Security
-implementation("androidx.security:security-crypto:1.1.0-alpha06")
+implementation("androidx.security:security-crypto:1.1.0")
```
**Plugin Added**:
```kotlin
-id("com.google.devtools.ksp") version "1.9.20-1.0.14"
+id("com.google.devtools.ksp") version "2.3.8"
```
---
diff --git a/prompts/fix-api-migration-issues.md b/prompts/fix-api-migration-issues.md
index c2ba015..cd2f27c 100644
--- a/prompts/fix-api-migration-issues.md
+++ b/prompts/fix-api-migration-issues.md
@@ -100,7 +100,7 @@ timestamp = (tracker.startTime / 1000.0), // 1703462400.0
- Copy complete test from review doc section "Priority 2: Retrofit Path Generation Test"
- Add dependency to `src/app/build.gradle.kts`:
```kotlin
- testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+ testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
```
- Run test: `./gradlew app:testDebugUnitTest --tests "RetrofitPathTest"`
diff --git a/prompts/performance_id_and_tests_first.md b/prompts/performance_id_and_tests_first.md
index 3447cac..ea49a1b 100644
--- a/prompts/performance_id_and_tests_first.md
+++ b/prompts/performance_id_and_tests_first.md
@@ -13,7 +13,7 @@ Users report poor performance on **spotty cellular** and **slow Wi-Fi**. Your jo
- Images: Coil
- Concurrency: Kotlin Coroutines/Flows
- Tests available/expected: JUnit, AndroidX Test, Espresso, Compose UI testing
-- Toolchain: Kotlin 1.9.20, Gradle 8.12, AGP 8.10.0; minSdk 21, targetSdk 35
+- Toolchain: Kotlin 2.3.21 with AGP built-in Kotlin, Gradle 9.5.1, AGP 9.2.1; minSdk 23, targetSdk 36
- Build/run: `cd src && ./gradlew build` / `./gradlew installDebug`
## Ground Rules
@@ -101,4 +101,3 @@ Provide a table:
- Clear, maintainable assertions; minimal mocking; no dead code in tests.
> Output everything as described and stop before making production code changes. Recommend changes; don’t apply them.
-
diff --git a/src/app/build.gradle.kts b/src/app/build.gradle.kts
index b9c1d69..17fdb7b 100644
--- a/src/app/build.gradle.kts
+++ b/src/app/build.gradle.kts
@@ -1,7 +1,7 @@
plugins {
id("com.android.application")
- id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
+ id("org.jetbrains.kotlin.plugin.compose")
id("jacoco")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
@@ -9,14 +9,14 @@ plugins {
android {
namespace = "com.melodee.autoplayer"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
applicationId = "com.melodee.autoplayer"
- minSdk = 21
- targetSdk = 35
- versionCode = 11
- versionName = "1.7.3"
+ minSdk = 23
+ targetSdk = 36
+ versionCode = 12
+ versionName = "1.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -41,15 +41,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = "17"
- }
buildFeatures {
compose = true
}
- composeOptions {
- kotlinCompilerExtensionVersion = "1.5.4"
- }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -59,36 +53,50 @@ android {
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" // seen in some jars
}
+ jniLibs {
+ keepDebugSymbols += setOf(
+ "**/libandroidx.graphics.path.so",
+ "**/libbenchmarkNative.so",
+ "**/libdatastore_shared_counter.so",
+ "**/libtracing_perfetto.so"
+ )
+ }
+ }
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
- val media3Version = "1.2.0"
+ val media3Version = "1.10.1"
// Core Android
- implementation("androidx.core:core-ktx:1.15.0")
- implementation("androidx.appcompat:appcompat:1.7.0")
- implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.core:core-ktx:1.18.0")
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("com.google.android.material:material:1.14.0")
// Security - Encrypted SharedPreferences
- implementation("androidx.security:security-crypto:1.1.0-alpha06")
+ implementation("androidx.security:security-crypto:1.1.0")
// Lifecycle
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
- implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
// Compose
- implementation("androidx.activity:activity-compose:1.9.3")
- implementation(platform("androidx.compose:compose-bom:2024.12.01"))
+ implementation("androidx.activity:activity-compose:1.13.0")
+ implementation(platform("androidx.compose:compose-bom:2026.05.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
- implementation("androidx.navigation:navigation-compose:2.8.5")
+ implementation("androidx.navigation:navigation-compose:2.9.8")
// Coil - Image Loading
implementation("io.coil-kt:coil:2.7.0")
@@ -96,14 +104,14 @@ dependencies {
implementation("io.coil-kt:coil-gif:2.7.0")
// Networking
- implementation("com.squareup.retrofit2:retrofit:2.11.0")
- implementation("com.squareup.retrofit2:converter-gson:2.11.0")
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
- implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ implementation("com.squareup.retrofit2:retrofit:3.0.0")
+ implementation("com.squareup.retrofit2:converter-gson:3.0.0")
+ implementation("com.squareup.okhttp3:okhttp:5.3.2")
+ implementation("com.squareup.okhttp3:logging-interceptor:5.3.2")
// Coroutines
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
// Media3
implementation("androidx.media3:media3-exoplayer:$media3Version")
@@ -114,26 +122,26 @@ dependencies {
implementation("androidx.media3:media3-database:$media3Version")
// Media support for Android Auto
- implementation("androidx.media:media:1.7.0")
+ implementation("androidx.media:media:1.8.0")
// Testing
testImplementation("junit:junit:4.13.2")
- testImplementation("io.mockk:mockk:1.13.10")
+ testImplementation("io.mockk:mockk:1.14.9")
testImplementation("androidx.arch.core:core-testing:2.2.0")
- testImplementation("com.google.truth:truth:1.1.3")
- testImplementation("app.cash.turbine:turbine:1.0.0")
- testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
- testImplementation("org.robolectric:robolectric:4.11.1")
- androidTestImplementation("androidx.test.ext:junit:1.2.1")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
- androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
+ testImplementation("com.google.truth:truth:1.4.5")
+ testImplementation("app.cash.turbine:turbine:1.2.1")
+ testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
+ testImplementation("org.robolectric:robolectric:4.16.1")
+ androidTestImplementation("androidx.test.ext:junit:1.3.0")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2026.05.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
- androidTestImplementation("io.mockk:mockk-android:1.13.10")
+ androidTestImplementation("io.mockk:mockk-android:1.14.9")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Firebase
- implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
+ implementation(platform("com.google.firebase:firebase-bom:34.13.0"))
implementation("com.google.firebase:firebase-crashlytics")
implementation("com.google.firebase:firebase-analytics")
@@ -141,7 +149,7 @@ dependencies {
// JaCoCo coverage configuration
tasks.register("jacocoTestReport") {
- dependsOn("testDebugUnitTest", "createDebugCoverageReport")
+ dependsOn("testDebugUnitTest")
reports {
xml.required.set(true)
@@ -157,14 +165,20 @@ tasks.register("jacocoTestReport") {
"android/**/*.*"
)
- val debugTree = fileTree("${buildDir}/intermediates/classes/debug") {
+ val kotlinDebugTree = fileTree(layout.buildDirectory.dir("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes")) {
+ exclude(fileFilter)
+ }
+ val javaDebugTree = fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) {
exclude(fileFilter)
}
val mainSrc = "${project.projectDir}/src/main/java"
sourceDirectories.setFrom(files(mainSrc))
- classDirectories.setFrom(files(debugTree))
- executionData.setFrom(fileTree(buildDir) {
- include("**/*.exec", "**/*.ec")
+ classDirectories.setFrom(files(kotlinDebugTree, javaDebugTree))
+ executionData.setFrom(fileTree(layout.buildDirectory) {
+ include(
+ "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec",
+ "jacoco/testDebugUnitTest.exec"
+ )
})
-}
+}
diff --git a/src/app/src/main/AndroidManifest.xml b/src/app/src/main/AndroidManifest.xml
index b4257ad..dfd85cb 100644
--- a/src/app/src/main/AndroidManifest.xml
+++ b/src/app/src/main/AndroidManifest.xml
@@ -7,7 +7,6 @@
-
@@ -62,7 +61,9 @@
android:enabled="true"
android:exported="true"
android:label="Melodee"
- android:foregroundServiceType="mediaPlayback">
+ android:foregroundServiceType="mediaPlayback"
+ tools:ignore="ExportedService">
+
diff --git a/src/app/src/main/java/com/melodee/autoplayer/MelodeeApplication.kt b/src/app/src/main/java/com/melodee/autoplayer/MelodeeApplication.kt
index 738cddf..08b63e1 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/MelodeeApplication.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/MelodeeApplication.kt
@@ -28,21 +28,22 @@ class MelodeeApplication : Application(), ImageLoaderFactory {
Log.i("MelodeeApplication", "Initializing AuthenticationManager...")
authenticationManager = AuthenticationManager(this)
- // Initialize NetworkModule to enable HTTP cache and auth failure handling
+ // Initialize NetworkModule to enable HTTP cache. AuthenticationManager owns auth failure handling.
NetworkModule.init(this)
- NetworkModule.setAuthenticationFailureCallback {
- Log.w("MelodeeApplication", "Authentication failure detected by NetworkModule")
- // Potential hook: route to login screen via a global event bus
- }
Log.i("MelodeeApplication", "Authentication manager initialized")
Log.i("MelodeeApplication", "=== APPLICATION STARTUP COMPLETE ===")
}
override fun newImageLoader(): ImageLoader {
+ val isDebuggable = applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
val loggingInterceptor = HttpLoggingInterceptor { message ->
Log.d("OkHttp", message)
}.apply {
- level = HttpLoggingInterceptor.Level.BODY
+ level = if (isDebuggable) {
+ HttpLoggingInterceptor.Level.BASIC
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
}
return ImageLoader.Builder(this)
@@ -55,7 +56,9 @@ class MelodeeApplication : Application(), ImageLoaderFactory {
.addInterceptor { chain ->
val request = chain.request()
val response = chain.proceed(request)
- Log.d("OkHttp", "Response headers for ${request.url}: ${response.headers}")
+ if (isDebuggable) {
+ Log.d("OkHttp", "Response headers for ${request.url}: ${response.headers}")
+ }
response
}
.addInterceptor(loggingInterceptor)
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/AuthenticationManager.kt b/src/app/src/main/java/com/melodee/autoplayer/data/AuthenticationManager.kt
index 100fa06..e8058bf 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/AuthenticationManager.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/AuthenticationManager.kt
@@ -27,9 +27,12 @@ class AuthenticationManager(private val context: Context) {
}
// Persist refreshed tokens when NetworkModule updates them
- NetworkModule.setTokenUpdateCallback { token, refresh ->
+ NetworkModule.setTokenUpdateCallback { token, refresh, refreshExpiresAt ->
settingsManager.authToken = token
settingsManager.refreshToken = refresh
+ if (refreshExpiresAt.isNotBlank()) {
+ settingsManager.refreshTokenExpiresAt = refreshExpiresAt
+ }
}
}
@@ -48,9 +51,7 @@ class AuthenticationManager(private val context: Context) {
Log.i("AuthenticationManager", "Username present: $hasUsername")
Log.i("AuthenticationManager", "User email present: $hasUserEmail")
- if (hasAuthToken) {
- Log.i("AuthenticationManager", "Auth token: ${settingsManager.authToken.take(20)}...")
- }
+ Log.i("AuthenticationManager", "Auth token present: $hasAuthToken")
if (hasServerUrl) {
Log.i("AuthenticationManager", "Server URL: ${settingsManager.serverUrl}")
}
@@ -119,7 +120,7 @@ class AuthenticationManager(private val context: Context) {
// Update NetworkModule
NetworkModule.setBaseUrl(serverUrl)
- NetworkModule.setTokens(token, refreshToken)
+ NetworkModule.setTokens(token, settingsManager.refreshToken)
// Update state
_isAuthenticated.value = true
@@ -146,25 +147,12 @@ class AuthenticationManager(private val context: Context) {
private fun handleAuthenticationFailure() {
Log.w("AuthenticationManager", "Authentication failed - token expired or invalid")
-
- // Before clearing everything, check if we actually have stored credentials
- val hasStoredAuth = settingsManager.isAuthenticated()
-
- if (hasStoredAuth) {
- Log.i("AuthenticationManager", "Still have stored credentials - may be temporary network issue")
- // Don't clear stored authentication for temporary issues
- // Just update the state to show we need to re-authenticate
- _isAuthenticated.value = false
- _authenticationError.value = "Authentication failed. Please try again or re-login if the problem persists."
- } else {
- Log.i("AuthenticationManager", "No stored credentials - clearing authentication")
- // Clear stored authentication
- settingsManager.logout()
-
- // Update state
- _isAuthenticated.value = false
- _authenticationError.value = "Your session has expired. Please login again."
- }
+
+ // NetworkModule only invokes this callback for non-recoverable auth failures.
+ // Transient refresh/network failures keep stored credentials so Android Auto can retry.
+ settingsManager.logout()
+ _isAuthenticated.value = false
+ _authenticationError.value = "Your session has expired. Please login again."
Log.d("AuthenticationManager", "Authentication failure handled - user needs to re-authenticate")
}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/SecureSettingsManager.kt b/src/app/src/main/java/com/melodee/autoplayer/data/SecureSettingsManager.kt
index 2d77f96..f3b8c0f 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/SecureSettingsManager.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/SecureSettingsManager.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION")
+
package com.melodee.autoplayer.data
import android.content.Context
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/SettingsManager.kt b/src/app/src/main/java/com/melodee/autoplayer/data/SettingsManager.kt
index 03cd9cf..c7fe4d3 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/SettingsManager.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/SettingsManager.kt
@@ -1,12 +1,23 @@
+@file:Suppress("DEPRECATION")
+
package com.melodee.autoplayer.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
-import java.util.UUID
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
class SettingsManager(context: Context) {
- private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val appContext = context.applicationContext
+ private val prefs: SharedPreferences = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val securePrefs: SharedPreferences = createSecurePreferences(appContext)
+ private val isUsingSecurePrefsFallback: Boolean
+ get() = securePrefs === prefs
+
+ init {
+ migrateSensitiveValuesToSecurePrefs()
+ }
var serverUrl: String
get() = prefs.getString(KEY_SERVER_URL, "") ?: ""
@@ -34,26 +45,33 @@ class SettingsManager(context: Context) {
var authToken: String
get() {
- val token = prefs.getString(KEY_AUTH_TOKEN, "") ?: ""
- Log.d("SettingsManager", "Getting auth token: ${if (token.isNotEmpty()) "${token.take(20)}..." else "empty"}")
+ val token = securePrefs.getString(KEY_AUTH_TOKEN, "") ?: ""
+ Log.d("SettingsManager", "Getting auth token: ${if (token.isNotEmpty()) "present" else "empty"}")
return token
}
set(value) {
- Log.d("SettingsManager", "Setting auth token: ${if (value.isNotEmpty()) "${value.take(20)}..." else "empty"}")
- prefs.edit().putString(KEY_AUTH_TOKEN, value).apply()
+ Log.d("SettingsManager", "Setting auth token: ${if (value.isNotEmpty()) "present" else "empty"}")
+ securePrefs.edit().putString(KEY_AUTH_TOKEN, value).apply()
+ removePlaintextTokenKey(KEY_AUTH_TOKEN)
}
var refreshToken: String
- get() = prefs.getString(KEY_REFRESH_TOKEN, "") ?: ""
- set(value) = prefs.edit().putString(KEY_REFRESH_TOKEN, value).apply()
+ get() = securePrefs.getString(KEY_REFRESH_TOKEN, "") ?: ""
+ set(value) {
+ securePrefs.edit().putString(KEY_REFRESH_TOKEN, value).apply()
+ removePlaintextTokenKey(KEY_REFRESH_TOKEN)
+ }
var refreshTokenExpiresAt: String
- get() = prefs.getString(KEY_REFRESH_TOKEN_EXPIRES_AT, "") ?: ""
- set(value) = prefs.edit().putString(KEY_REFRESH_TOKEN_EXPIRES_AT, value).apply()
+ get() = securePrefs.getString(KEY_REFRESH_TOKEN_EXPIRES_AT, "") ?: ""
+ set(value) {
+ securePrefs.edit().putString(KEY_REFRESH_TOKEN_EXPIRES_AT, value).apply()
+ removePlaintextTokenKey(KEY_REFRESH_TOKEN_EXPIRES_AT)
+ }
// Check if user is currently authenticated
fun isAuthenticated(): Boolean {
- val hasToken = authToken.isNotEmpty()
+ val hasToken = authToken.isNotEmpty() || refreshToken.isNotEmpty()
val hasServerUrl = serverUrl.isNotEmpty()
val result = hasToken && hasServerUrl
@@ -78,7 +96,7 @@ class SettingsManager(context: Context) {
imageUrl: String = ""
) {
Log.i("SettingsManager", "=== SAVING AUTHENTICATION DATA ===")
- Log.i("SettingsManager", "Token: ${if (token.isNotEmpty()) "${token.take(20)}..." else "empty"}")
+ Log.i("SettingsManager", "Token present: ${token.isNotEmpty()}")
Log.i("SettingsManager", "User ID: $userId")
Log.i("SettingsManager", "Username: $username")
Log.i("SettingsManager", "Email: $userEmail")
@@ -87,19 +105,35 @@ class SettingsManager(context: Context) {
Log.i("SettingsManager", "Image URL: $imageUrl")
Log.i("SettingsManager", "Refresh token present: ${refreshToken.isNotEmpty()}")
- val editor = prefs.edit()
- editor.putString(KEY_AUTH_TOKEN, token)
- editor.putString(KEY_REFRESH_TOKEN, refreshToken)
- editor.putString(KEY_REFRESH_TOKEN_EXPIRES_AT, refreshTokenExpiresAt)
- editor.putString(KEY_USER_ID, userId)
- editor.putString(KEY_USER_EMAIL, userEmail)
- editor.putString(KEY_USER_NAME, username)
- editor.putString(KEY_SERVER_URL, serverUrl)
- editor.putString(KEY_USER_THUMBNAIL_URL, thumbnailUrl)
- editor.putString(KEY_USER_IMAGE_URL, imageUrl)
- val success = editor.commit() // Use commit() instead of apply() for synchronous save
+ val refreshTokenToStore = refreshToken.ifBlank { existingRefreshTokenFor(userId, serverUrl) }
+ val refreshTokenExpiresAtToStore = refreshTokenExpiresAt.ifBlank {
+ if (refreshTokenToStore.isNotBlank()) this.refreshTokenExpiresAt else ""
+ }
+
+ val secureSuccess = securePrefs.edit()
+ .putString(KEY_AUTH_TOKEN, token)
+ .putString(KEY_REFRESH_TOKEN, refreshTokenToStore)
+ .putString(KEY_REFRESH_TOKEN_EXPIRES_AT, refreshTokenExpiresAtToStore)
+ .commit()
+
+ val regularEditor = prefs.edit()
+ .putString(KEY_USER_ID, userId)
+ .putString(KEY_USER_EMAIL, userEmail)
+ .putString(KEY_USER_NAME, username)
+ .putString(KEY_SERVER_URL, serverUrl)
+ .putString(KEY_USER_THUMBNAIL_URL, thumbnailUrl)
+ .putString(KEY_USER_IMAGE_URL, imageUrl)
+
+ if (!isUsingSecurePrefsFallback) {
+ regularEditor
+ .remove(KEY_AUTH_TOKEN)
+ .remove(KEY_REFRESH_TOKEN)
+ .remove(KEY_REFRESH_TOKEN_EXPIRES_AT)
+ }
+
+ val success = regularEditor.commit() // Use commit() instead of apply() for synchronous save
- Log.i("SettingsManager", "Authentication data saved successfully: $success")
+ Log.i("SettingsManager", "Authentication data saved successfully: secure=$secureSuccess, regular=$success")
// Verify the save
Log.d("SettingsManager", "Verification - Token stored: ${authToken.isNotEmpty()}")
@@ -122,10 +156,67 @@ class SettingsManager(context: Context) {
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_REFRESH_TOKEN_EXPIRES_AT)
.apply()
+ securePrefs.edit()
+ .remove(KEY_AUTH_TOKEN)
+ .remove(KEY_REFRESH_TOKEN)
+ .remove(KEY_REFRESH_TOKEN_EXPIRES_AT)
+ .apply()
+ }
+
+ private fun createSecurePreferences(context: Context): SharedPreferences {
+ return try {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ EncryptedSharedPreferences.create(
+ context,
+ SECURE_PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ } catch (e: Exception) {
+ Log.e("SettingsManager", "Failed to create encrypted preferences; using regular preferences fallback", e)
+ prefs
+ }
+ }
+
+ private fun migrateSensitiveValuesToSecurePrefs() {
+ val oldAuthToken = prefs.getString(KEY_AUTH_TOKEN, "") ?: ""
+ val oldRefreshToken = prefs.getString(KEY_REFRESH_TOKEN, "") ?: ""
+ val oldRefreshExpiresAt = prefs.getString(KEY_REFRESH_TOKEN_EXPIRES_AT, "") ?: ""
+ if (oldAuthToken.isBlank() && oldRefreshToken.isBlank() && oldRefreshExpiresAt.isBlank()) return
+
+ securePrefs.edit()
+ .putString(KEY_AUTH_TOKEN, oldAuthToken)
+ .putString(KEY_REFRESH_TOKEN, oldRefreshToken)
+ .putString(KEY_REFRESH_TOKEN_EXPIRES_AT, oldRefreshExpiresAt)
+ .apply()
+ if (securePrefs !== prefs) {
+ prefs.edit()
+ .remove(KEY_AUTH_TOKEN)
+ .remove(KEY_REFRESH_TOKEN)
+ .remove(KEY_REFRESH_TOKEN_EXPIRES_AT)
+ .apply()
+ }
+ Log.i("SettingsManager", "Migrated authentication tokens to secure preferences")
+ }
+
+ private fun removePlaintextTokenKey(key: String) {
+ if (!isUsingSecurePrefsFallback) {
+ prefs.edit().remove(key).apply()
+ }
+ }
+
+ private fun existingRefreshTokenFor(userId: String, serverUrl: String): String {
+ val sameUser = this.userId == userId && this.serverUrl == serverUrl
+ return if (sameUser) refreshToken else ""
}
companion object {
private const val PREFS_NAME = "MelodeePrefs"
+ private const val SECURE_PREFS_NAME = "MelodeeSecurePrefs"
private const val KEY_SERVER_URL = "server_url"
private const val KEY_USER_ID = "user_id"
private const val KEY_USER_EMAIL = "user_email"
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/api/MusicApi.kt b/src/app/src/main/java/com/melodee/autoplayer/data/api/MusicApi.kt
index a68d63e..9045f47 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/api/MusicApi.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/api/MusicApi.kt
@@ -12,9 +12,9 @@ interface MusicApi {
@GET("api/v1/system/info")
suspend fun getSystemInfo(): ServerInfo
- @POST("api/v1/auth/refresh")
+ @POST("api/v1/auth/refresh-token")
suspend fun refresh(
- @Body request: RefreshRequest,
+ @Body request: RefreshTokenRequest,
@Header("X-Refresh-Request") skipAuthHeader: Boolean = true
): AuthenticationResponse
@@ -25,12 +25,12 @@ interface MusicApi {
@GET("api/v1/user/playlists")
suspend fun getPlaylists(
@Query("page") page: Int,
- @Query("pageSize") pageSize: Int = 50
+ @Query("limit") pageSize: Int = 50
): PlaylistPagedResponse
- @GET("api/v1/playlists/{id}/songs")
+ @GET("api/v1/playlists/{apiKey}/songs")
suspend fun getPlaylistSongs(
- @Path("id") playlistId: String,
+ @Path("apiKey") playlistId: String,
@Query("page") page: Int,
@Query("pageSize") pageSize: Int = 50
): SongPagedResponse
@@ -68,14 +68,12 @@ interface MusicApi {
@GET("api/v1/albums/{id}/songs")
suspend fun getAlbumSongs(
- @Path("id") albumId: String,
- @Query("page") page: Int,
- @Query("pageSize") pageSize: Int = 50
+ @Path("id") albumId: String
): SongPagedResponse
- @POST("api/v1/songs/starred/{id}/{isStarred}")
+ @POST("api/v1/songs/starred/{apiKey}/{isStarred}")
suspend fun favoriteSong(
- @Path("id") songId: String,
+ @Path("apiKey") songId: String,
@Path("isStarred") userStarred: Boolean
): retrofit2.Response
}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/api/NetworkModule.kt b/src/app/src/main/java/com/melodee/autoplayer/data/api/NetworkModule.kt
index 9997f2e..c46e669 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/api/NetworkModule.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/api/NetworkModule.kt
@@ -7,12 +7,18 @@ import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import com.melodee.autoplayer.util.Logger
-import com.melodee.autoplayer.domain.model.RefreshRequest
+import com.melodee.autoplayer.domain.model.RefreshTokenRequest
import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
+import java.io.IOException
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -30,6 +36,7 @@ object NetworkModule {
private var retrofit: Retrofit? = null
private var musicApi: MusicApi? = null
private var scrobbleApi: ScrobbleApi? = null
+ private var refreshApi: MusicApi? = null
// Thread-safe locks for configuration changes
private val configLock = ReentrantReadWriteLock()
@@ -38,12 +45,18 @@ object NetworkModule {
@Volatile
private var onAuthenticationFailure: (() -> Unit)? = null
@Volatile
- private var onTokenUpdated: ((String, String) -> Unit)? = null
+ private var onTokenUpdated: ((String, String, String) -> Unit)? = null
// Thread-safe flag to prevent multiple 401 callbacks
private val handlingAuthFailure = AtomicBoolean(false)
private val refreshLock = Any()
+ private enum class TokenRefreshResult {
+ SUCCESS,
+ INVALID_CREDENTIALS,
+ TRANSIENT_FAILURE
+ }
+
fun init(context: android.content.Context) {
// Keep application context only
appContext = context.applicationContext
@@ -75,8 +88,10 @@ object NetworkModule {
authToken = token
refreshToken = refresh
handlingAuthFailure.set(false)
- Logger.d("NetworkModule", "Recreating Retrofit instance with updated tokens")
- createRetrofitInstance()
+ if (retrofit == null && baseUrl.isNotEmpty()) {
+ Logger.d("NetworkModule", "Creating Retrofit instance after token update")
+ createRetrofitInstance()
+ }
Logger.logAuth("NetworkModule", "Tokens configured")
}
}
@@ -100,7 +115,7 @@ object NetworkModule {
onAuthenticationFailure = callback
}
- fun setTokenUpdateCallback(callback: (newAccessToken: String, newRefreshToken: String) -> Unit) {
+ fun setTokenUpdateCallback(callback: (newAccessToken: String, newRefreshToken: String, refreshTokenExpiresAt: String) -> Unit) {
onTokenUpdated = callback
}
@@ -123,6 +138,8 @@ object NetworkModule {
private fun createRetrofitInstance() {
val ctx = appContext
+ val gson = createGson()
+ refreshApi = createRefreshApi(gson)
// Configure dispatcher and connection pool for better control
val dispatcher = okhttp3.Dispatcher().apply {
@@ -145,37 +162,18 @@ object NetworkModule {
val skipAuth = original.header("X-Refresh-Request") == "true"
val requestBuilder = original.newBuilder()
.method(original.method, original.body)
+ val currentAuthToken = configLock.read { authToken }
- if (!skipAuth && authToken.isNotEmpty()) {
- requestBuilder.header("Authorization", "Bearer $authToken")
- }
-
- var response = chain.proceed(requestBuilder.build())
-
- // Handle 401 Unauthorized responses with a single refresh attempt
- if (!skipAuth && response.code == 401) {
- val refreshed = attemptTokenRefresh()
- if (refreshed) {
- response.close()
- val retry = original.newBuilder()
- .method(original.method, original.body)
- .header("Authorization", "Bearer $authToken")
- .build()
- response = chain.proceed(retry)
- } else if (handlingAuthFailure.compareAndSet(false, true)) {
- Log.w("NetworkModule", "Received 401 Unauthorized - token expired and refresh failed")
- clearAuthentication()
- onAuthenticationFailure?.invoke()
- }
+ if (!skipAuth && currentAuthToken.isNotEmpty()) {
+ requestBuilder.header("Authorization", "Bearer $currentAuthToken")
}
- response
+ chain.proceed(requestBuilder.build())
}
+ .authenticator(TokenAuthenticator())
// Retry with simple exponential backoff for idempotent requests and 429/5xx
.addInterceptor(RetryInterceptor(maxRetries = 3))
- .addInterceptor(HttpLoggingInterceptor().apply {
- level = HttpLoggingInterceptor.Level.BODY
- })
+ .addInterceptor(createLoggingInterceptor())
// Conservative, but responsive timeouts
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
@@ -205,6 +203,35 @@ object NetworkModule {
val okHttpClient = okHttpBuilder.build()
// Create Gson with explicit UUID adapter for safe error handling
+ retrofit = Retrofit.Builder()
+ .baseUrl(baseUrl)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+
+ musicApi = retrofit?.create(MusicApi::class.java)
+ scrobbleApi = retrofit?.create(ScrobbleApi::class.java)
+ }
+
+ private fun createRefreshApi(gson: com.google.gson.Gson): MusicApi? {
+ if (baseUrl.isEmpty()) return null
+
+ val refreshClient = OkHttpClient.Builder()
+ .addInterceptor(createLoggingInterceptor())
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .writeTimeout(20, TimeUnit.SECONDS)
+ .build()
+
+ return Retrofit.Builder()
+ .baseUrl(baseUrl)
+ .client(refreshClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+ .create(MusicApi::class.java)
+ }
+
+ private fun createGson(): com.google.gson.Gson {
val gson = GsonBuilder()
.registerTypeAdapter(UUID::class.java, object : TypeAdapter() {
override fun write(out: JsonWriter, value: UUID?) {
@@ -236,32 +263,108 @@ object NetworkModule {
})
.create()
- retrofit = Retrofit.Builder()
- .baseUrl(baseUrl)
- .client(okHttpClient)
- .addConverterFactory(GsonConverterFactory.create(gson))
- .build()
-
- musicApi = retrofit?.create(MusicApi::class.java)
- scrobbleApi = retrofit?.create(ScrobbleApi::class.java)
+ return gson
}
- private fun attemptTokenRefresh(): Boolean {
+ private fun attemptTokenRefresh(): TokenRefreshResult {
synchronized(refreshLock) {
- if (refreshToken.isEmpty() || retrofit == null) return false
+ val currentRefreshToken = configLock.read { refreshToken }
+ val api = refreshApi
+ if (currentRefreshToken.isEmpty() || api == null) return TokenRefreshResult.INVALID_CREDENTIALS
return try {
- val api = retrofit!!.create(MusicApi::class.java)
val refreshResponse = runBlocking {
- api.refresh(RefreshRequest(refreshToken), skipAuthHeader = true)
+ api.refresh(RefreshTokenRequest(currentRefreshToken), skipAuthHeader = true)
+ }
+ val newRefreshToken = refreshResponse.refreshToken.orEmpty().ifBlank { currentRefreshToken }
+ setTokens(refreshResponse.token, newRefreshToken)
+ onTokenUpdated?.invoke(
+ refreshResponse.token,
+ newRefreshToken,
+ refreshResponse.refreshTokenExpiresAt.orEmpty()
+ )
+ TokenRefreshResult.SUCCESS
+ } catch (e: HttpException) {
+ if (e.code() == 400 || e.code() == 401 || e.code() == 403) {
+ Log.e("NetworkModule", "Token refresh rejected by server with HTTP ${e.code()}")
+ TokenRefreshResult.INVALID_CREDENTIALS
+ } else {
+ Log.e("NetworkModule", "Token refresh failed with recoverable HTTP ${e.code()}", e)
+ TokenRefreshResult.TRANSIENT_FAILURE
}
- setTokens(refreshResponse.token, refreshResponse.refreshToken)
- onTokenUpdated?.invoke(refreshResponse.token, refreshResponse.refreshToken)
- true
+ } catch (e: IOException) {
+ Log.e("NetworkModule", "Token refresh failed due to network error; credentials retained", e)
+ TokenRefreshResult.TRANSIENT_FAILURE
} catch (e: Exception) {
- Log.e("NetworkModule", "Token refresh failed", e)
- false
+ Log.e("NetworkModule", "Token refresh failed unexpectedly; credentials retained", e)
+ TokenRefreshResult.TRANSIENT_FAILURE
+ }
+ }
+ }
+
+ private class TokenAuthenticator : Authenticator {
+ override fun authenticate(route: Route?, response: Response): Request? {
+ if (response.request.header("X-Refresh-Request") == "true") return null
+ if (responseCount(response) > 1) return null
+
+ val requestToken = response.request.header("Authorization")?.removePrefix("Bearer ")
+ val currentToken = configLock.read { authToken }
+ if (currentToken.isNotEmpty() && currentToken != requestToken) {
+ return response.request.newBuilder()
+ .header("Authorization", "Bearer $currentToken")
+ .build()
+ }
+
+ return when (attemptTokenRefresh()) {
+ TokenRefreshResult.SUCCESS -> {
+ val refreshedToken = configLock.read { authToken }
+ if (refreshedToken.isEmpty()) {
+ null
+ } else {
+ response.request.newBuilder()
+ .header("Authorization", "Bearer $refreshedToken")
+ .build()
+ }
+ }
+ TokenRefreshResult.INVALID_CREDENTIALS -> {
+ if (handlingAuthFailure.compareAndSet(false, true)) {
+ Log.w("NetworkModule", "Received 401 Unauthorized - refresh token is invalid or expired")
+ clearAuthentication()
+ onAuthenticationFailure?.invoke()
+ }
+ null
+ }
+ TokenRefreshResult.TRANSIENT_FAILURE -> {
+ Log.w("NetworkModule", "Received 401 Unauthorized, but token refresh failed transiently; keeping stored authentication for retry")
+ null
+ }
}
}
+
+ private fun responseCount(response: Response): Int {
+ var count = 1
+ var prior = response.priorResponse
+ while (prior != null) {
+ count++
+ prior = prior.priorResponse
+ }
+ return count
+ }
+ }
+
+ private fun createLoggingInterceptor(): HttpLoggingInterceptor {
+ return HttpLoggingInterceptor().apply {
+ redactHeader("Authorization")
+ level = if (isDebuggable()) {
+ HttpLoggingInterceptor.Level.BASIC
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ }
+ }
+
+ private fun isDebuggable(): Boolean {
+ val flags = appContext?.applicationInfo?.flags ?: return false
+ return flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
}
fun getMusicApi(): MusicApi {
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/api/ScrobbleApi.kt b/src/app/src/main/java/com/melodee/autoplayer/data/api/ScrobbleApi.kt
index 5aa3e69..c696287 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/api/ScrobbleApi.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/api/ScrobbleApi.kt
@@ -13,18 +13,17 @@ import retrofit2.http.POST
data class ScrobbleRequest(
val songId: String,
val playerName: String = "MelodeePlayer",
- @Deprecated("Use scrobbleTypeValue")
- val scrobbleType: String? = null,
+ val scrobbleTypeValue: ScrobbleRequestType,
+ val scrobbleType: String = scrobbleTypeValue.apiName,
val timestamp: Double,
- val playedDuration: Double,
- val scrobbleTypeValue: ScrobbleRequestType
+ val playedDuration: Double
)
@JsonAdapter(ScrobbleRequestTypeAdapter::class)
-enum class ScrobbleRequestType(val value: Int) {
- UNKNOWN(0),
- NOW_PLAYING(1),
- PLAYED(2)
+enum class ScrobbleRequestType(val value: Int, val apiName: String) {
+ UNKNOWN(0, "unknown"),
+ NOW_PLAYING(1, "nowPlaying"),
+ PLAYED(2, "played")
}
class ScrobbleRequestTypeAdapter : TypeAdapter() {
@@ -43,10 +42,9 @@ data class ScrobbleResponse(
)
data class ScrobbleErrorResponse(
- val type: String,
- val title: String,
- val status: Int,
- val traceId: String
+ val code: String = "unknown",
+ val message: String = "",
+ val correlationId: String? = null
)
// Sealed class to represent either success or error response
@@ -77,20 +75,18 @@ fun Response.toScrobbleResult(): ScrobbleResult {
} catch (e: JsonSyntaxException) {
// If we can't parse the error response, create a generic one
val genericError = ScrobbleErrorResponse(
- type = "unknown",
- title = "HTTP Error ${code()}",
- status = code(),
- traceId = "unknown"
+ code = "http_${code()}",
+ message = "HTTP Error ${code()}",
+ correlationId = null
)
ScrobbleResult.Error(genericError, code())
}
} else {
// No error body, create a generic error
val genericError = ScrobbleErrorResponse(
- type = "unknown",
- title = "HTTP Error ${code()}",
- status = code(),
- traceId = "unknown"
+ code = "http_${code()}",
+ message = "HTTP Error ${code()}",
+ correlationId = null
)
ScrobbleResult.Error(genericError, code())
}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/data/repository/MusicRepository.kt b/src/app/src/main/java/com/melodee/autoplayer/data/repository/MusicRepository.kt
index 32a6d14..c6fae3b 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/data/repository/MusicRepository.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/data/repository/MusicRepository.kt
@@ -62,7 +62,7 @@ class MusicRepository(private val baseUrl: String, private val context: Context)
LoginModel(userName = emailOrUsername, password = password)
}
val response = api.login(loginModel)
- NetworkModule.setTokens(response.token, response.refreshToken)
+ NetworkModule.setTokens(response.token, response.refreshToken.orEmpty())
response
}
emit(response)
@@ -132,7 +132,7 @@ class MusicRepository(private val baseUrl: String, private val context: Context)
fun getAlbumSongs(albumId: String, page: Int = 1): Flow> = flow {
val response = ErrorHandler.handleOperation(context, "getAlbumSongs", "MusicRepository") {
- api.getAlbumSongs(albumId, page)
+ api.getAlbumSongs(albumId)
}
emit(PaginatedResponse(meta = response.meta, data = response.data))
}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/domain/model/Models.kt b/src/app/src/main/java/com/melodee/autoplayer/domain/model/Models.kt
index 02e898e..3e4eb68 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/domain/model/Models.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/domain/model/Models.kt
@@ -173,7 +173,7 @@ data class Artist(
val createdAt: String = "",
val updatedAt: String = "",
val biography: String? = null,
- val genres: List = emptyList()
+ val genres: List? = emptyList()
) : Parcelable {
constructor(parcel: Parcel) : this(
id = parcel.readUuidString(),
@@ -202,7 +202,7 @@ data class Artist(
parcel.writeString(createdAt)
parcel.writeString(updatedAt)
parcel.writeString(biography)
- parcel.writeStringList(genres)
+ parcel.writeStringList(genres.orEmpty())
}
override fun describeContents(): Int = 0
@@ -279,8 +279,8 @@ data class AuthenticationResponse(
var serverVersion: String,
val user: User,
val expiresAt: String = "",
- val refreshToken: String = "",
- val refreshTokenExpiresAt: String = ""
+ val refreshToken: String? = null,
+ val refreshTokenExpiresAt: String? = null
)
typealias AuthResponse = AuthenticationResponse
@@ -291,10 +291,13 @@ data class LoginModel(
val password: String
)
-data class RefreshRequest(
- val refreshToken: String
+data class RefreshTokenRequest(
+ val refreshToken: String,
+ val deviceId: String? = null
)
+typealias RefreshRequest = RefreshTokenRequest
+
data class ServerInfo(
val name: String? = null,
val description: String? = null,
diff --git a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/MainActivity.kt b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/MainActivity.kt
index 50fb080..ee1723b 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/MainActivity.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/MainActivity.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION")
+
package com.melodee.autoplayer.presentation.ui
import android.Manifest
diff --git a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/home/HomeViewModel.kt b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/home/HomeViewModel.kt
index 1efe91f..58a8a40 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/home/HomeViewModel.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/home/HomeViewModel.kt
@@ -1,5 +1,6 @@
package com.melodee.autoplayer.presentation.ui.home
+import android.annotation.SuppressLint
import android.app.Application
import android.content.ComponentName
import android.content.Context
@@ -23,8 +24,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.Job
import java.util.UUID
-import java.util.ArrayList
+@SuppressLint("StaticFieldLeak")
class HomeViewModel(application: Application) : AndroidViewModel(application) {
private var repository: MusicRepository? = null
private var musicService: MusicService? = null
@@ -32,28 +33,28 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
private var searchJob: Job? = null
private var performanceMonitor: PerformanceMonitor? = null
private var didAttemptUserRefresh = false
-
+
companion object {
private const val MAX_SONGS_IN_MEMORY = 150
private const val KEEP_SONGS_ON_CLEANUP = 75
}
-
+
private fun updateSongsWithVirtualScrolling(newSongs: List, isFirstPage: Boolean): List {
return if (isFirstPage) {
newSongs
} else {
val currentSongs = _songs.value
val combinedSongs = currentSongs + newSongs
-
+
// If we exceed memory limit, keep only the most recent songs
if (combinedSongs.size > MAX_SONGS_IN_MEMORY) {
- combinedSongs.takeLast(KEEP_SONGS_ON_CLEANUP) + newSongs
+ combinedSongs.takeLast(KEEP_SONGS_ON_CLEANUP)
} else {
combinedSongs
}
}
}
-
+
private val _user = MutableStateFlow(null)
val user: StateFlow = _user.asStateFlow()
@@ -67,7 +68,6 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val isLoading: StateFlow = _isLoading.asStateFlow()
private val _searchQuery = MutableStateFlow("")
- val searchQuery: StateFlow = _searchQuery.asStateFlow()
private val _selectedArtist = MutableStateFlow(null)
val selectedArtist: StateFlow = _selectedArtist.asStateFlow()
@@ -76,7 +76,6 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val artists: StateFlow> = _artists.asStateFlow()
private val _artistSearchQuery = MutableStateFlow("")
- val artistSearchQuery: StateFlow = _artistSearchQuery.asStateFlow()
private val _isArtistLoading = MutableStateFlow(false)
val isArtistLoading: StateFlow = _isArtistLoading.asStateFlow()
@@ -86,13 +85,13 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val _isAlbumsLoading = MutableStateFlow(false)
val isAlbumsLoading: StateFlow = _isAlbumsLoading.asStateFlow()
-
+
private val _totalAlbums = MutableStateFlow(0)
val totalAlbums: StateFlow = _totalAlbums.asStateFlow()
-
+
private val _currentAlbumsStart = MutableStateFlow(0)
val currentAlbumsStart: StateFlow = _currentAlbumsStart.asStateFlow()
-
+
private val _currentAlbumsEnd = MutableStateFlow(0)
val currentAlbumsEnd: StateFlow = _currentAlbumsEnd.asStateFlow()
@@ -119,7 +118,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow = _isPlaying.asStateFlow()
-
+
// Keep track of the currently playing song, independent of UI state
private val _currentPlayingSong = MutableStateFlow(null)
val currentPlayingSong: StateFlow = _currentPlayingSong.asStateFlow()
@@ -127,19 +126,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val _playbackProgress = MutableStateFlow(0f)
val playbackProgress: StateFlow = _playbackProgress.asStateFlow()
- private val _currentPosition = MutableStateFlow(0L)
- val currentPosition: StateFlow = _currentPosition.asStateFlow()
-
- private val _currentDuration = MutableStateFlow(0L)
- val currentDuration: StateFlow = _currentDuration.asStateFlow()
-
private var currentPage = 1
private var hasMorePlaylists = true
private var hasMoreSongs = true
private var currentSearchPage = 1
private var currentArtistPage = 1
private var hasMoreArtists = true
- private var progressUpdateJob: Job? = null
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
@@ -147,103 +139,87 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
musicService = binder.getService()
bound = true
Log.d("HomeViewModel", "Service connected, isPlaying: ${_isPlaying.value}")
-
+
// Start observing service state
observeServiceState()
-
- // Ensure progress updates start if we should be playing
- ensureProgressUpdatesStarted()
}
override fun onServiceDisconnected(arg0: ComponentName) {
musicService = null
bound = false
Log.d("HomeViewModel", "Service disconnected")
- stopProgressUpdates()
}
}
- private fun updateProgress() {
- val duration = _currentDuration.value
- val position = _currentPosition.value
- _playbackProgress.value = if (duration > 0) position.toFloat() / duration else 0f
- }
-
private fun observeServiceState() {
viewModelScope.launch {
musicService?.let { service ->
- // Observe playlist manager state to track current song changes
- service.getPlaylistManager().currentSong.collect { song ->
- Log.d("HomeViewModel", "Service current song updated: ${song?.title}")
-
- // Check if the current playback context is SEARCH
- val playbackContext = service.getCurrentPlaybackContext()
- Log.d("HomeViewModel", "Current playback context: $playbackContext")
-
- if (song != null) {
- // Update the currently playing song regardless of context
- _currentPlayingSong.value = song
-
- // Find the song in our current songs list and update the index
- val index = _songs.value.indexOf(song)
- if (index >= 0) {
- Log.d("HomeViewModel", "Found song in HomeViewModel songs at index: $index (context: $playbackContext)")
- // Update both index and playing state to ensure mini player shows
- _currentSongIndex.value = index
- _isPlaying.value = service.isPlaying()
- Log.d("HomeViewModel", "Updated currentSongIndex to: $index, isPlaying: ${_isPlaying.value}")
- } else {
- Log.d("HomeViewModel", "Song not found in current HomeViewModel songs")
- // Song is playing but not in our current view (different context)
- if (playbackContext != MusicService.PlaybackContext.SEARCH &&
+ launch {
+ service.currentSongFlow().collect { song ->
+ Log.d("HomeViewModel", "Service current song updated: ${song?.title}")
+ val playbackContext = service.getCurrentPlaybackContext()
+ Log.d("HomeViewModel", "Current playback context: $playbackContext")
+
+ if (song != null) {
+ _currentPlayingSong.value = song
+
+ val index = _songs.value.indexOf(song)
+ if (index >= 0) {
+ Log.d("HomeViewModel", "Found song in HomeViewModel songs at index: $index (context: $playbackContext)")
+ _currentSongIndex.value = index
+ } else if (playbackContext != MusicService.PlaybackContext.SEARCH &&
playbackContext != MusicService.PlaybackContext.PLAYLIST) {
- // Only reset if it's a completely different context (not search or playlist)
_currentSongIndex.value = -1
_isPlaying.value = false
Log.d("HomeViewModel", "Different playback context ($playbackContext), resetting home state")
}
- }
- } else if (playbackContext != MusicService.PlaybackContext.SEARCH &&
- playbackContext != MusicService.PlaybackContext.PLAYLIST) {
- Log.d("HomeViewModel", "Playback context is not SEARCH/PLAYLIST ($playbackContext), resetting home state")
- // If playback context is completely different, reset our state
- _currentSongIndex.value = -1
- _isPlaying.value = false
- } else {
- Log.d("HomeViewModel", "Service current song is null")
- // Only reset if we're not currently playing from search
- if (_searchQuery.value.isBlank()) {
+ } else if (playbackContext != MusicService.PlaybackContext.SEARCH &&
+ playbackContext != MusicService.PlaybackContext.PLAYLIST) {
+ Log.d("HomeViewModel", "Playback context is not SEARCH/PLAYLIST ($playbackContext), resetting home state")
_currentSongIndex.value = -1
_isPlaying.value = false
+ } else {
+ Log.d("HomeViewModel", "Service current song is null")
+ if (_searchQuery.value.isBlank()) {
+ _currentSongIndex.value = -1
+ _isPlaying.value = false
+ }
}
}
}
- }
- }
-
- viewModelScope.launch {
- musicService?.let { service ->
- // Observe playing state from the service more frequently for better responsiveness
- while (bound && musicService != null) {
- val isServicePlaying = service.isPlaying()
- val playbackContext = service.getCurrentPlaybackContext()
-
- // Update if we're in SEARCH/PLAYLIST context and there's a meaningful change
- if ((playbackContext == MusicService.PlaybackContext.SEARCH ||
- playbackContext == MusicService.PlaybackContext.PLAYLIST) &&
- _currentSongIndex.value >= 0 &&
- _isPlaying.value != isServicePlaying) {
- Log.d("HomeViewModel", "Service playing state changed: $isServicePlaying (was: ${_isPlaying.value}) in $playbackContext context")
- _isPlaying.value = isServicePlaying
- Log.d("HomeViewModel", "Updated isPlaying from service: $isServicePlaying")
- } else if (playbackContext != MusicService.PlaybackContext.SEARCH &&
- playbackContext != MusicService.PlaybackContext.PLAYLIST &&
- _isPlaying.value) {
- Log.d("HomeViewModel", "Playback context changed to $playbackContext, stopping home playback state")
- _isPlaying.value = false
- _currentSongIndex.value = -1
+
+ launch {
+ combine(
+ service.isPlayingFlow(),
+ service.currentPlaybackContextFlow()
+ ) { isPlaying, playbackContext ->
+ Pair(isPlaying, playbackContext)
+ }.collect { (isPlaying, playbackContext) ->
+ if (playbackContext == MusicService.PlaybackContext.SEARCH ||
+ playbackContext == MusicService.PlaybackContext.PLAYLIST) {
+ if (_currentSongIndex.value >= 0) {
+ _isPlaying.value = isPlaying
+ }
+ } else if (_isPlaying.value) {
+ Log.d("HomeViewModel", "Playback context changed to $playbackContext, stopping home playback state")
+ _isPlaying.value = false
+ _currentSongIndex.value = -1
+ }
+ }
+ }
+
+ launch {
+ combine(
+ service.currentDurationFlow(),
+ service.currentPositionFlow()
+ ) { duration, position ->
+ Pair(duration, position)
+ }.collect { (duration, position) ->
+ if (duration > 0) {
+ _playbackProgress.value = position.toFloat() / duration.toFloat()
+ Log.v("HomeViewModel", "Progress update: ${position}ms / ${duration}ms (${(_playbackProgress.value * 100).toInt()}%)")
+ }
}
- delay(500) // Check more frequently for better responsiveness
}
}
}
@@ -290,29 +266,6 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
- // Start progress updates when playing
- viewModelScope.launch {
- _isPlaying.collect { playing ->
- Log.d("HomeViewModel", "isPlaying changed to: $playing, bound: $bound")
- if (playing && bound) {
- ensureProgressUpdatesStarted()
- } else {
- stopProgressUpdates()
- }
- }
- }
-
- // Periodic check to ensure progress updates are running when they should be
- viewModelScope.launch {
- while (true) {
- delay(5000) // Check every 5 seconds
- if (_isPlaying.value && bound && musicService != null && progressUpdateJob == null) {
- Log.w("HomeViewModel", "Progress updates should be running but aren't - restarting")
- ensureProgressUpdatesStarted()
- }
- }
- }
-
loadInitialData()
}
@@ -345,44 +298,10 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
- private fun ensureProgressUpdatesStarted() {
- if (_isPlaying.value && bound && musicService != null && progressUpdateJob == null) {
- Log.d("HomeViewModel", "Ensuring progress updates are started")
- startProgressUpdates()
- }
- }
-
- private fun startProgressUpdates() {
- progressUpdateJob?.cancel()
- Log.d("HomeViewModel", "Starting progress updates, bound: $bound, musicService: ${musicService != null}")
- progressUpdateJob = viewModelScope.launch {
- while (true) {
- musicService?.let { service ->
- val duration = service.getDuration()
- val position = service.getCurrentPosition()
- if (duration > 0) {
- _currentDuration.value = duration
- _currentPosition.value = position
- _playbackProgress.value = position.toFloat() / duration.toFloat()
- Log.v("HomeViewModel", "Progress update: ${position}ms / ${duration}ms (${(_playbackProgress.value * 100).toInt()}%)")
- }
- } ?: run {
- Log.w("HomeViewModel", "MusicService is null during progress update")
- }
- delay(1000) // Update every second
- }
- }
- }
-
- private fun stopProgressUpdates() {
- Log.d("HomeViewModel", "Stopping progress updates")
- progressUpdateJob?.cancel()
- progressUpdateJob = null
- }
+ // Progress state is now driven directly by MusicService flow state.
override fun onCleared() {
super.onCleared()
- stopProgressUpdates()
performanceMonitor?.stopMonitoring()
if (bound) {
getApplication().unbindService(connection)
@@ -397,7 +316,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
currentPage = 1
hasMorePlaylists = true
_playlists.value = emptyList()
-
+
// Note: Playlists will be loaded when setUser() is called after authentication
}
@@ -424,7 +343,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
Log.d("HomeViewModel", "Skipping playlist load: hasMore=$hasMorePlaylists, repository=${repository != null}, isLoading=${_isLoading.value}")
return
}
-
+
viewModelScope.launch {
_isLoading.value = true
try {
@@ -463,7 +382,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun clearSearchAndStopPlayback() {
// Clear search
_searchQuery.value = ""
-
+
// Stop playback if playing
if (_isPlaying.value) {
val ctx = getApplication()
@@ -473,16 +392,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
ctx.startService(intent)
_isPlaying.value = false
}
-
+
// Reset playback state
_currentSongIndex.value = -1
_playbackProgress.value = 0f
- _currentPosition.value = 0L
- _currentDuration.value = 0L
-
- // Stop progress updates
- stopProgressUpdates()
-
+
Log.d("HomeViewModel", "Cleared search and stopped playback")
}
@@ -494,7 +408,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
_isLoading.value = true
try {
val selectedArtistId = _selectedArtist.value?.id?.toString()
-
+
// Only perform search when query is not blank
if (query.isNotBlank()) {
// Use appropriate search method based on artist filter
@@ -531,6 +445,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
} catch (e: Exception) {
+ Log.e("HomeViewModel", "Error searching songs", e)
_isLoading.value = false
}
}
@@ -548,7 +463,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun setUser(user: User) {
Log.d("HomeViewModel", "Setting user: ${user.username}")
_user.value = user
-
+
// Load playlists when user is set (after authentication)
// Artists will be loaded on-demand when user searches
if (repository != null) {
@@ -564,25 +479,29 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
Log.d("HomeViewModel", "Total songs in search results: ${songs.value.size}")
Log.d("HomeViewModel", "Current search query: '${_searchQuery.value}'")
Log.d("HomeViewModel", "Current bound state: $bound")
-
+
if (index != -1) {
Log.d("HomeViewModel", "Setting state - currentSongIndex: $index, isPlaying: true")
_currentSongIndex.value = index
_isPlaying.value = true
_currentPlayingSong.value = song // Store the currently playing song
-
+
Log.d("HomeViewModel", "State after setting - currentSongIndex: ${_currentSongIndex.value}, isPlaying: ${_isPlaying.value}")
-
+
val ctx = getApplication()
// If we have search results, set them as the queue with search context
if (songs.value.isNotEmpty() && _searchQuery.value.isNotBlank()) {
- Log.d("HomeViewModel", "Sending ACTION_SET_SEARCH_RESULTS to service")
- val intent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_SET_SEARCH_RESULTS
- putParcelableArrayListExtra(MusicService.EXTRA_SEARCH_RESULTS, ArrayList(songs.value))
- putExtra("START_INDEX", index)
+ Log.d("HomeViewModel", "Playing service-side search queue")
+ val service = musicService
+ if (service != null) {
+ service.playSearchQueue(songs.value, index)
+ } else {
+ val intent = Intent(ctx, MusicService::class.java).apply {
+ action = MusicService.ACTION_PLAY_SONG
+ putExtra(MusicService.EXTRA_SONG, song)
+ }
+ ctx.startService(intent)
}
- ctx.startService(intent)
} else {
Log.d("HomeViewModel", "Sending ACTION_PLAY_SONG to service (single song)")
// Single song playback
@@ -592,14 +511,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
ctx.startService(intent)
}
-
- // Ensure progress updates start (will work immediately if bound, or later when service connects)
- ensureProgressUpdatesStarted()
-
+
// Add a verification mechanism to ensure state is correct after a short delay
viewModelScope.launch {
delay(1000) // Wait 1 second for service to process
-
+
// Verify that our state is still correct
if (_currentSongIndex.value == index && _isPlaying.value) {
Log.d("HomeViewModel", "State verification: OK - index: $index, playing: true")
@@ -610,7 +526,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
_isPlaying.value = true
Log.d("HomeViewModel", "Forced state correction")
}
-
+
// Also verify service state if bound
if (bound && musicService != null) {
val serviceIsPlaying = musicService!!.isPlaying()
@@ -620,15 +536,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
-
- // If service is not bound yet, retry after a delay
- if (!bound) {
- Log.d("HomeViewModel", "Service not bound yet, will retry progress updates")
- viewModelScope.launch {
- delay(500)
- ensureProgressUpdatesStarted()
- }
- }
+
} else {
Log.e("HomeViewModel", "Song not found in search results!")
}
@@ -666,7 +574,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
ctx.startService(intent)
}
-
+
// Clear all data
_user.value = null
_playlists.value = emptyList()
@@ -682,17 +590,15 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
_currentSongIndex.value = -1
_isPlaying.value = false
_playbackProgress.value = 0f
- _currentPosition.value = 0L
- _currentDuration.value = 0L
_isLoading.value = false
-
+
// Reset album/artist view states to return to normal playlist view
_albums.value = emptyList()
_isAlbumsLoading.value = false
_showAlbums.value = false
_selectedAlbum.value = null
_showAlbumSongs.value = false
-
+
// Reset pagination
currentPage = 1
hasMorePlaylists = true
@@ -700,24 +606,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
currentSearchPage = 1
currentArtistPage = 1
hasMoreArtists = true
-
- // Stop progress updates
- stopProgressUpdates()
-
+
// Clear repository
repository = null
-
- Log.d("HomeViewModel", "Logout completed - all data cleared")
- }
- fun clearSearch() {
- _searchQuery.value = ""
- _songs.value = emptyList()
- _totalSearchResults.value = 0
- _currentPageStart.value = 0
- _currentPageEnd.value = 0
- currentSearchPage = 1
- hasMoreSongs = true
+ Log.d("HomeViewModel", "Logout completed - all data cleared")
}
fun favoriteSong(song: Song, newStarredValue: Boolean) {
@@ -726,7 +619,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
Log.d("HomeViewModel", "favoriteSong called: songId=${song.id}, newStarredValue=$newStarredValue")
val success = repository?.favoriteSong(song.id.toString(), newStarredValue) ?: false
Log.d("HomeViewModel", "favoriteSong API result: success=$success")
-
+
if (success) {
// Update the song in the current list
val updatedSongs = _songs.value.map { currentSong ->
@@ -738,10 +631,10 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
_songs.value = updatedSongs
Log.d("HomeViewModel", "Updated song list, new size: ${updatedSongs.size}")
-
+
// Refresh playlists since favorite status changes may affect playlist details
refreshPlaylists()
-
+
// Show success toast
val ctx = getApplication()
val message = if (newStarredValue) "Favorited Song" else "Un-favorited Song"
@@ -762,30 +655,9 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
- fun clearQueue() {
- Log.d("HomeViewModel", "Clearing queue")
- val ctx = getApplication()
- val intent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_CLEAR_QUEUE
- }
- ctx.startService(intent)
-
- // Clear local playback state
- _currentSongIndex.value = -1
- _isPlaying.value = false
- _playbackProgress.value = 0f
- _currentPosition.value = 0L
- _currentDuration.value = 0L
-
- // Stop progress updates
- stopProgressUpdates()
-
- Log.d("HomeViewModel", "Queue cleared")
- }
-
private fun performArtistSearch(query: String) {
if (repository == null) return
-
+
viewModelScope.launch {
_isArtistLoading.value = true
try {
@@ -811,25 +683,25 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun selectArtist(artist: Artist?) {
_selectedArtist.value = artist
-
+
// Clear current songs and reset pagination
_songs.value = emptyList()
currentSearchPage = 1
hasMoreSongs = true
-
+
// Clear album view states when artist changes
_albums.value = emptyList()
_isAlbumsLoading.value = false
_showAlbums.value = false
_selectedAlbum.value = null
_showAlbumSongs.value = false
-
+
// If artist is being cleared (set to null), clear the artist results but keep search capability
if (artist == null) {
_artists.value = emptyList()
_isArtistLoading.value = false
}
-
+
// When artist is selected with existing search text: Re-filter with artist constraint
if (artist != null && _searchQuery.value.isNotBlank()) {
performSearch(_searchQuery.value)
@@ -851,11 +723,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun browseArtistAlbums() {
val artist = _selectedArtist.value ?: return
-
+
_showAlbums.value = true
_albums.value = emptyList()
_isAlbumsLoading.value = true
-
+
viewModelScope.launch {
try {
repository?.getArtistAlbums(artist.id.toString(), 1)
@@ -890,7 +762,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
_showAlbumSongs.value = true
_showAlbums.value = false
_songs.value = emptyList()
-
+
viewModelScope.launch {
try {
_isLoading.value = true
@@ -932,15 +804,19 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
_currentSongIndex.value = 0
_isPlaying.value = true
_currentPlayingSong.value = albumSongs.first()
-
+
val ctx = getApplication()
Log.d("HomeViewModel", "Playing album: ${album.name} with ${albumSongs.size} songs")
- val intent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_SET_SEARCH_RESULTS
- putParcelableArrayListExtra(MusicService.EXTRA_SEARCH_RESULTS, ArrayList(albumSongs))
- putExtra("START_INDEX", 0)
+ val service = musicService
+ if (service != null) {
+ service.playSearchQueue(albumSongs, 0)
+ } else {
+ val intent = Intent(ctx, MusicService::class.java).apply {
+ action = MusicService.ACTION_PLAY_SONG
+ putExtra(MusicService.EXTRA_SONG, albumSongs.first())
+ }
+ ctx.startService(intent)
}
- ctx.startService(intent)
}
}
} catch (e: Exception) {
@@ -951,17 +827,17 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun browseArtistSongs() {
val artist = _selectedArtist.value ?: return
-
+
// Clear current songs and search
_songs.value = emptyList()
_searchQuery.value = ""
currentSearchPage = 1
hasMoreSongs = true
-
+
// Hide albums view to show songs
_showAlbums.value = false
_showAlbumSongs.value = false
-
+
// Load all songs for this artist
viewModelScope.launch {
try {
diff --git a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/login/LoginViewModel.kt b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/login/LoginViewModel.kt
index 50cbd7f..ec6c53f 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/login/LoginViewModel.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/login/LoginViewModel.kt
@@ -60,17 +60,6 @@ class LoginViewModel(application: Application) : AndroidViewModel(application) {
_isLoading.value = false
}
?.collect { response ->
- // Store user information in settings including avatar URLs
- settingsManager.userId = response.user.id.toString()
- settingsManager.userEmail = response.user.email
- settingsManager.username = response.user.username
- settingsManager.userThumbnailUrl = response.user.thumbnailUrl
- settingsManager.userImageUrl = response.user.imageUrl
- settingsManager.authToken = response.token
- settingsManager.refreshToken = response.refreshToken
- settingsManager.refreshTokenExpiresAt = response.refreshTokenExpiresAt
-
- // Inform global AuthenticationManager so app state reflects authenticated
try {
val app = getApplication() as com.melodee.autoplayer.MelodeeApplication
app.authenticationManager.saveAuthentication(
@@ -79,13 +68,24 @@ class LoginViewModel(application: Application) : AndroidViewModel(application) {
userEmail = response.user.email,
username = response.user.username,
serverUrl = this@LoginViewModel.serverUrl,
- refreshToken = response.refreshToken,
- refreshTokenExpiresAt = response.refreshTokenExpiresAt,
+ refreshToken = response.refreshToken.orEmpty(),
+ refreshTokenExpiresAt = response.refreshTokenExpiresAt.orEmpty(),
thumbnailUrl = response.user.thumbnailUrl,
imageUrl = response.user.imageUrl
)
} catch (e: Exception) {
Log.w("LoginViewModel", "Failed to update AuthenticationManager on login", e)
+ settingsManager.saveAuthenticationData(
+ token = response.token,
+ userId = response.user.id.toString(),
+ userEmail = response.user.email,
+ username = response.user.username,
+ serverUrl = this@LoginViewModel.serverUrl,
+ refreshToken = response.refreshToken.orEmpty(),
+ refreshTokenExpiresAt = response.refreshTokenExpiresAt.orEmpty(),
+ thumbnailUrl = response.user.thumbnailUrl,
+ imageUrl = response.user.imageUrl
+ )
}
// After successful authentication, stop any playing song and clear queue
@@ -121,8 +121,13 @@ class LoginViewModel(application: Application) : AndroidViewModel(application) {
// URL normalization is now handled by UrlParser.normalizeServerUrl()
fun logout() {
- // Clear user data from settings
- settingsManager.clearUserData()
+ try {
+ val app = getApplication() as com.melodee.autoplayer.MelodeeApplication
+ app.authenticationManager.logout()
+ } catch (e: Exception) {
+ Log.w("LoginViewModel", "Failed to update AuthenticationManager on logout", e)
+ settingsManager.clearUserData()
+ }
// Reset login state
_loginState.value = LoginState.Initial
diff --git a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/nowplaying/NowPlayingViewModel.kt b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/nowplaying/NowPlayingViewModel.kt
index fcc6114..ed434f6 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/nowplaying/NowPlayingViewModel.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/nowplaying/NowPlayingViewModel.kt
@@ -249,13 +249,20 @@ class NowPlayingViewModel : ViewModel() {
fun setQueue(songs: List, startIndex: Int = 0) {
Log.d("NowPlayingViewModel", "Set queue with ${songs.size} songs")
- context?.let { ctx ->
- val intent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_SET_PLAYLIST
- putParcelableArrayListExtra(MusicService.EXTRA_PLAYLIST, ArrayList(songs))
- putExtra("START_INDEX", startIndex)
+ val service = musicService
+ if (service != null) {
+ service.playPlaylistQueue(songs, startIndex)
+ return
+ }
+
+ songs.getOrNull(startIndex)?.let { song ->
+ context?.let { ctx ->
+ val intent = Intent(ctx, MusicService::class.java).apply {
+ action = MusicService.ACTION_PLAY_SONG
+ putExtra(MusicService.EXTRA_SONG, song)
+ }
+ ctx.startService(intent)
}
- ctx.startService(intent)
}
}
@@ -332,4 +339,4 @@ class NowPlayingViewModel : ViewModel() {
Log.d("NowPlayingViewModel", "Logout completed - all data cleared")
}
-}
\ No newline at end of file
+}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/playlist/PlaylistViewModel.kt b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/playlist/PlaylistViewModel.kt
index d8caace..f4137cb 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/playlist/PlaylistViewModel.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/presentation/ui/playlist/PlaylistViewModel.kt
@@ -1,15 +1,12 @@
package com.melodee.autoplayer.presentation.ui.playlist
+import android.annotation.SuppressLint
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
-import android.Manifest
-import android.content.pm.PackageManager
-import android.os.Build
import android.os.IBinder
-import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.melodee.autoplayer.data.repository.MusicRepository
@@ -18,11 +15,9 @@ import com.melodee.autoplayer.domain.model.Song
import com.melodee.autoplayer.service.MusicService
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.Job
-import java.util.*
import android.util.Log
+@SuppressLint("StaticFieldLeak")
class PlaylistViewModel(application: Application) : AndroidViewModel(application) {
private var repository: MusicRepository? = null
private var context: Context? = null
@@ -35,19 +30,14 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
val binder = service as MusicService.MusicBinder
musicService = binder.getService()
bound = true
-
+
// Start observing service state
observeServiceState()
-
- if (isPlaying.value) {
- startProgressUpdates()
- }
}
override fun onServiceDisconnected(arg0: ComponentName) {
musicService = null
bound = false
- stopProgressUpdates()
}
}
@@ -63,7 +53,7 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
this.context!!.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
-
+
fun setOnPlaylistsNeedRefresh(callback: () -> Unit) {
onPlaylistsNeedRefresh = callback
}
@@ -80,12 +70,6 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
private val _playbackProgress = MutableStateFlow(0f)
val playbackProgress: StateFlow = _playbackProgress.asStateFlow()
- private val _currentDuration = MutableStateFlow(0L)
- val currentDuration: StateFlow = _currentDuration.asStateFlow()
-
- private val _currentPosition = MutableStateFlow(0L)
- val currentPosition: StateFlow = _currentPosition.asStateFlow()
-
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow = _isLoading.asStateFlow()
@@ -94,20 +78,19 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
private val _shouldScrollToTop = MutableStateFlow(false)
val shouldScrollToTop: StateFlow = _shouldScrollToTop.asStateFlow()
-
+
private val _totalSongs = MutableStateFlow(0)
val totalSongs: StateFlow = _totalSongs.asStateFlow()
-
+
private val _currentSongsStart = MutableStateFlow(0)
val currentSongsStart: StateFlow = _currentSongsStart.asStateFlow()
-
+
private val _currentSongsEnd = MutableStateFlow(0)
val currentSongsEnd: StateFlow = _currentSongsEnd.asStateFlow()
- private var progressUpdateJob: Job? = null
-
private var currentPage = 1
private var hasMoreSongs = true
+ private var currentPlaylistPageSize = 50
private var currentPlaylistId: String? = null
private var isRefreshing = false
private var autoPlayAfterRefresh = false
@@ -120,98 +103,48 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
}
}
- init {
- // Start progress updates when playing
+ private fun observeServiceState() {
viewModelScope.launch {
- _isPlaying.collect { playing ->
- if (playing && bound) {
- startProgressUpdates()
- } else {
- stopProgressUpdates()
- }
- }
- }
- }
-
- private fun hasMediaControlPermission(): Boolean {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- ContextCompat.checkSelfPermission(
- context ?: return false,
- Manifest.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK
- ) == PackageManager.PERMISSION_GRANTED
- } else {
- true // Permission not required for older Android versions
- }
- }
-
- private fun startProgressUpdates() {
- progressUpdateJob?.cancel()
- progressUpdateJob = viewModelScope.launch {
- while (true) {
- musicService?.let { service ->
- val duration = service.getDuration()
- val position = service.getCurrentPosition()
- if (duration > 0) {
- _currentDuration.value = duration
- _currentPosition.value = position
- _playbackProgress.value = position.toFloat() / duration.toFloat()
+ musicService?.let { service ->
+ launch {
+ service.currentSongFlow().collect { song ->
+ Log.d("PlaylistViewModel", "Service current song updated: ${song?.title}")
+ val playbackContext = service.getCurrentPlaybackContext()
+ Log.d("PlaylistViewModel", "Current playback context: $playbackContext")
+
+ if (song != null && playbackContext == MusicService.PlaybackContext.PLAYLIST && _songs.value.contains(song)) {
+ Log.d("PlaylistViewModel", "Setting current song: ${song.title} (PLAYLIST context)")
+ _currentSong.value = song
+ } else if (playbackContext != MusicService.PlaybackContext.PLAYLIST) {
+ Log.d("PlaylistViewModel", "Playback context is not PLAYLIST ($playbackContext), resetting playlist state")
+ _currentSong.value = null
+ _isPlaying.value = false
+ }
}
}
- delay(1000) // Update every second
- }
- }
- }
-
- private fun stopProgressUpdates() {
- progressUpdateJob?.cancel()
- progressUpdateJob = null
- }
- private fun observeServiceState() {
- viewModelScope.launch {
- musicService?.let { service ->
- // Observe playlist manager state to track current song changes
- service.getPlaylistManager().currentSong.collect { song ->
- Log.d("PlaylistViewModel", "Service current song updated: ${song?.title}")
-
- // Check if the current playback context is PLAYLIST
- val playbackContext = service.getCurrentPlaybackContext()
- Log.d("PlaylistViewModel", "Current playback context: $playbackContext")
-
- if (song != null && playbackContext == MusicService.PlaybackContext.PLAYLIST && _songs.value.contains(song)) {
- // Only update if the song is in our current playlist and context is PLAYLIST
- Log.d("PlaylistViewModel", "Setting current song: ${song.title} (PLAYLIST context)")
- _currentSong.value = song
- _isPlaying.value = service.isPlaying()
- } else if (playbackContext != MusicService.PlaybackContext.PLAYLIST) {
- Log.d("PlaylistViewModel", "Playback context is not PLAYLIST ($playbackContext), resetting playlist state")
- // If playback context is not PLAYLIST, reset our state
- _currentSong.value = null
- _isPlaying.value = false
+ launch {
+ combine(service.isPlayingFlow(), service.currentPlaybackContextFlow()) { isPlaying, playbackContext ->
+ Pair(isPlaying, playbackContext)
+ }.collect { (isPlaying, playbackContext) ->
+ if (playbackContext == MusicService.PlaybackContext.PLAYLIST && _currentSong.value != null) {
+ _isPlaying.value = isPlaying
+ } else if (playbackContext != MusicService.PlaybackContext.PLAYLIST && _isPlaying.value) {
+ Log.d("PlaylistViewModel", "Playback context changed from PLAYLIST to $playbackContext, stopping playlist playback state")
+ _isPlaying.value = false
+ _currentSong.value = null
+ }
}
}
- }
- }
-
- viewModelScope.launch {
- musicService?.let { service ->
- // Observe playing state from the service
- while (bound && musicService != null) {
- val isServicePlaying = service.isPlaying()
- val playbackContext = service.getCurrentPlaybackContext()
-
- // Only update if we're in PLAYLIST context and there's a meaningful change
- if (playbackContext == MusicService.PlaybackContext.PLAYLIST &&
- _currentSong.value != null &&
- _isPlaying.value != isServicePlaying) {
- Log.d("PlaylistViewModel", "Service playing state changed: $isServicePlaying (was: ${_isPlaying.value}) in PLAYLIST context")
- _isPlaying.value = isServicePlaying
- } else if (playbackContext != MusicService.PlaybackContext.PLAYLIST && _isPlaying.value) {
- Log.d("PlaylistViewModel", "Playback context changed from PLAYLIST to $playbackContext, stopping playlist playback state")
- _isPlaying.value = false
- _currentSong.value = null
+
+ launch {
+ combine(service.currentDurationFlow(), service.currentPositionFlow()) { duration, position ->
+ Pair(duration, position)
+ }.collect { (duration, position) ->
+ if (duration > 0) {
+ _playbackProgress.value = position.toFloat() / duration.toFloat()
+ }
}
- delay(1000) // Check every second
}
}
}
@@ -219,41 +152,38 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
override fun onCleared() {
super.onCleared()
- stopProgressUpdates()
if (bound) {
getApplication().unbindService(connection)
bound = false
}
}
- val playlistName: String
- get() = _playlist.value?.name ?: ""
-
fun loadPlaylist(playlistId: String, allowPagination: Boolean = false) {
// Check if this is a different playlist than what's currently playing
val isNewPlaylist = playlistId != currentPlaylistId
-
+
// If it's the same playlist and we already have songs loaded, don't reload
- if (playlistId == currentPlaylistId && _songs.value.isNotEmpty() && !isNewPlaylist && !allowPagination) {
+ if (playlistId == currentPlaylistId && _songs.value.isNotEmpty() && !allowPagination) {
Log.d("PlaylistViewModel", "Playlist $playlistId already loaded, skipping reload")
return
}
-
+
// Don't auto-play when returning to an already loaded playlist
// This prevents restarting music when user navigates back from full-screen player
if (playlistId == currentPlaylistId && !hasMoreSongs && _songs.value.isNotEmpty()) {
Log.d("PlaylistViewModel", "Same playlist already loaded with no more songs, skipping auto-play")
return
}
-
+
// Reset pagination state for new playlist
if (isNewPlaylist) {
currentPage = 1
hasMoreSongs = true
+ currentPlaylistPageSize = 50
_songs.value = emptyList()
isRefreshing = false // Reset refresh state for new playlist
}
-
+
currentPlaylistId = playlistId
viewModelScope.launch {
_isLoading.value = true
@@ -280,8 +210,9 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
val allSongs = applyVirtualScrolling(_songs.value, response.data, currentPage == 1)
_songs.value = allSongs
hasMoreSongs = response.meta.hasNext
+ currentPlaylistPageSize = response.meta.pageSize.coerceAtLeast(1)
currentPage = response.meta.currentPage + 1
-
+
// Update pagination display values
_totalSongs.value = response.meta.totalCount
if (response.meta.currentPage == 1) {
@@ -292,40 +223,22 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
_currentSongsStart.value = prevEnd + 1
_currentSongsEnd.value = prevEnd + response.data.size
}
-
+
// If this was a refresh and we have songs, trigger scroll to top
if (isRefreshing && response.data.isNotEmpty() && currentPage == 2) {
_shouldScrollToTop.value = true
isRefreshing = false
}
-
+
// Auto-play the first song when loading a NEW playlist or after a manual refresh
if (response.data.isNotEmpty() && (isNewPlaylist || autoPlayAfterRefresh)) {
Log.d("PlaylistViewModel", "Auto-playing first song of new playlist: $playlistId")
- // Stop current playback if any
- context?.let { ctx ->
- val stopIntent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_STOP
- }
- ctx.startService(stopIntent)
- }
-
+
// Start playing the first song with playlist context
val firstSong = response.data.first()
_currentSong.value = firstSong
_isPlaying.value = true
- context?.let { ctx ->
- val playIntent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_SET_PLAYLIST
- putParcelableArrayListExtra(MusicService.EXTRA_PLAYLIST, ArrayList(_songs.value))
- putExtra("START_INDEX", 0)
- // Provide playlist pagination context so service can load more
- putExtra("PLAYLIST_ID", playlistId)
- putExtra("NEXT_PAGE", currentPage)
- putExtra("HAS_MORE", hasMoreSongs)
- }
- ctx.startService(playIntent)
- }
+ playPlaylistQueue(firstSong, 0)
autoPlayAfterRefresh = false
} else if (!isNewPlaylist) {
Log.d("PlaylistViewModel", "Returning to existing playlist $playlistId, preserving current playback")
@@ -357,6 +270,7 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
_isLoading.value = true
currentPage = 1
hasMoreSongs = true
+ currentPlaylistPageSize = 50
_songs.value = emptyList()
isRefreshing = true
autoPlayAfterRefresh = true
@@ -375,7 +289,7 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
Log.d("PlaylistViewModel", "favoriteSong called: songId=${song.id}, newStarredValue=$newStarredValue")
val success = repository?.favoriteSong(song.id.toString(), newStarredValue) ?: false
Log.d("PlaylistViewModel", "favoriteSong API result: success=$success")
-
+
if (success) {
// Update the song in the current list
val updatedSongs = _songs.value.map { currentSong ->
@@ -387,15 +301,15 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
}
_songs.value = updatedSongs
Log.d("PlaylistViewModel", "Updated song list, new size: ${updatedSongs.size}")
-
+
// Refresh the current playlist since favorite status changes may affect playlist details
currentPlaylistId?.let { playlistId ->
refreshPlaylistDetails(playlistId)
}
-
+
// Notify home page that playlists need to be refreshed
onPlaylistsNeedRefresh?.invoke()
-
+
// Show success toast
context?.let { ctx ->
val message = if (newStarredValue) "Favorited Song" else "Un-favorited Song"
@@ -447,6 +361,41 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
currentPlaylistId?.let { loadPlaylist(it, allowPagination = true) }
}
+ private fun playPlaylistQueue(song: Song, startIndex: Int) {
+ val playlistId = currentPlaylistId
+ val songsToPlay = _songs.value
+ val service = musicService
+ if (service != null) {
+ service.playPlaylistQueue(
+ songs = songsToPlay,
+ startIndex = startIndex,
+ playlistId = playlistId,
+ nextPage = currentPage,
+ hasMore = hasMoreSongs,
+ pageSize = currentPlaylistPageSize
+ )
+ return
+ }
+
+ context?.let { ctx ->
+ val intent = if (playlistId != null) {
+ Intent(ctx, MusicService::class.java).apply {
+ action = MusicService.ACTION_SET_PLAYLIST
+ putExtra(MusicService.EXTRA_START_INDEX, startIndex)
+ putExtra(MusicService.EXTRA_START_SONG_ID, song.id.toString())
+ putExtra(MusicService.EXTRA_PLAYLIST_ID, playlistId)
+ putExtra(MusicService.EXTRA_PLAYLIST_PAGE_SIZE, currentPlaylistPageSize)
+ }
+ } else {
+ Intent(ctx, MusicService::class.java).apply {
+ action = MusicService.ACTION_PLAY_SONG
+ putExtra(MusicService.EXTRA_SONG, song)
+ }
+ }
+ ctx.startService(intent)
+ }
+ }
+
fun playSong(song: Song) {
if (song == currentSong.value) {
// If the same song is clicked, toggle play/pause
@@ -455,22 +404,12 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
// Play the new song from playlist context
_currentSong.value = song
_isPlaying.value = true
- context?.let { ctx ->
- val songIndex = _songs.value.indexOf(song)
- if (songIndex >= 0) {
- // Set the entire playlist as the queue with playlist context
- val intent = Intent(ctx, MusicService::class.java).apply {
- action = MusicService.ACTION_SET_PLAYLIST
- putParcelableArrayListExtra(MusicService.EXTRA_PLAYLIST, ArrayList(_songs.value))
- putExtra("START_INDEX", songIndex)
- // Provide playlist pagination context so service can load more
- putExtra("PLAYLIST_ID", currentPlaylistId)
- putExtra("NEXT_PAGE", currentPage)
- putExtra("HAS_MORE", hasMoreSongs)
- }
- ctx.startService(intent)
- } else {
- // Fallback to single song if not found in playlist
+ val songIndex = _songs.value.indexOf(song)
+ if (songIndex >= 0) {
+ playPlaylistQueue(song, songIndex)
+ } else {
+ // Fallback to single song if not found in playlist
+ context?.let { ctx ->
val intent = Intent(ctx, MusicService::class.java).apply {
action = MusicService.ACTION_PLAY_SONG
putExtra(MusicService.EXTRA_SONG, song)
@@ -515,10 +454,6 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
}
}
- fun updatePlaybackProgress(progress: Float) {
- _playbackProgress.value = progress
- }
-
fun logout() {
// Stop any playing music
if (_isPlaying.value) {
@@ -529,26 +464,22 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
ctx.startService(intent)
}
}
-
+
// Clear all data
_playlist.value = null
_songs.value = emptyList()
_currentSong.value = null
_playbackProgress.value = 0f
- _currentDuration.value = 0L
- _currentPosition.value = 0L
_isLoading.value = false
_isPlaying.value = false
-
+
// Reset pagination
currentPage = 1
hasMoreSongs = true
+ currentPlaylistPageSize = 50
currentPlaylistId = null
-
- // Stop progress updates
- stopProgressUpdates()
-
+
// Clear repository
repository = null
}
-}
+}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/service/AndroidAutoBrowserClientValidator.kt b/src/app/src/main/java/com/melodee/autoplayer/service/AndroidAutoBrowserClientValidator.kt
new file mode 100644
index 0000000..c2189d2
--- /dev/null
+++ b/src/app/src/main/java/com/melodee/autoplayer/service/AndroidAutoBrowserClientValidator.kt
@@ -0,0 +1,12 @@
+package com.melodee.autoplayer.service
+
+internal object AndroidAutoBrowserClientValidator {
+ private val exactTrustedAndroidAutoPackages = setOf(
+ "com.google.android.projection.gearhead",
+ "com.google.android.gms"
+ )
+
+ fun isTrustedAndroidAutoPackageName(clientPackageName: String): Boolean {
+ return clientPackageName in exactTrustedAndroidAutoPackages
+ }
+}
diff --git a/src/app/src/main/java/com/melodee/autoplayer/service/MediaSessionManager.kt b/src/app/src/main/java/com/melodee/autoplayer/service/MediaSessionManager.kt
index 18a56a2..667a833 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/service/MediaSessionManager.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/service/MediaSessionManager.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION")
+
package com.melodee.autoplayer.service
import android.content.Context
diff --git a/src/app/src/main/java/com/melodee/autoplayer/service/MusicPlaybackManager.kt b/src/app/src/main/java/com/melodee/autoplayer/service/MusicPlaybackManager.kt
index b200ff0..c9b407a 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/service/MusicPlaybackManager.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/service/MusicPlaybackManager.kt
@@ -10,7 +10,6 @@ import com.melodee.autoplayer.util.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import android.util.Log
/**
* Consolidated music playback manager that combines queue, playlist, and player management
@@ -59,7 +58,6 @@ class MusicPlaybackManager(private val context: Context) {
val repeatMode: StateFlow = _repeatMode.asStateFlow()
private val _playHistory = MutableStateFlow>(emptyList())
- val playHistory: StateFlow> = _playHistory.asStateFlow()
private val _playbackContext = MutableStateFlow(PlaybackContext.SINGLE_SONG)
val playbackContext: StateFlow = _playbackContext.asStateFlow()
@@ -91,9 +89,7 @@ class MusicPlaybackManager(private val context: Context) {
}
return exoPlayer!!
}
-
- fun getPlayer(): ExoPlayer? = exoPlayer
-
+
fun setQueue(songs: List, startIndex: Int = 0, context: PlaybackContext = PlaybackContext.PLAYLIST) {
Logger.d("MusicPlaybackManager", "Setting queue with ${songs.size} songs, startIndex: $startIndex, context: $context")
@@ -149,25 +145,21 @@ class MusicPlaybackManager(private val context: Context) {
false
}
}
-
- fun playNext(): Boolean {
- val nextSong = getNextSong()
- return if (nextSong != null) {
- playSong(nextSong)
- } else {
- Logger.d("MusicPlaybackManager", "No next song available")
- false
- }
+
+ fun moveToNext(): Song? {
+ val nextSong = getNextSong() ?: return null
+ _currentSong.value = nextSong
+ addToHistory(nextSong)
+ Logger.d("MusicPlaybackManager", "Moved to next song: ${nextSong.title}")
+ return nextSong
}
-
- fun playPrevious(): Boolean {
- val previousSong = getPreviousSong()
- return if (previousSong != null) {
- playSong(previousSong)
- } else {
- Logger.d("MusicPlaybackManager", "No previous song available")
- false
- }
+
+ fun moveToPrevious(): Song? {
+ val previousSong = getPreviousSong() ?: return null
+ _currentSong.value = previousSong
+ addToHistory(previousSong)
+ Logger.d("MusicPlaybackManager", "Moved to previous song: ${previousSong.title}")
+ return previousSong
}
private fun getNextSong(): Song? {
@@ -323,22 +315,14 @@ class MusicPlaybackManager(private val context: Context) {
fun pause() {
exoPlayer?.pause()
}
-
- fun stop() {
- exoPlayer?.stop()
- _isPlaying.value = false
- _currentPosition.value = 0L
- }
-
- fun seekTo(position: Long) {
- exoPlayer?.seekTo(position)
+
+ fun updateProgress(position: Long, duration: Long) {
_currentPosition.value = position
+ if (duration > 0) {
+ _currentDuration.value = duration
+ }
}
-
- fun getCurrentPosition(): Long = exoPlayer?.currentPosition ?: 0L
-
- fun getDuration(): Long = exoPlayer?.duration ?: 0L
-
+
fun clearQueue() {
_originalQueue.value = emptyList()
_currentQueue.value = emptyList()
diff --git a/src/app/src/main/java/com/melodee/autoplayer/service/MusicService.kt b/src/app/src/main/java/com/melodee/autoplayer/service/MusicService.kt
index 8991f85..064aa25 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/service/MusicService.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/service/MusicService.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION")
+
package com.melodee.autoplayer.service
import androidx.media3.common.util.UnstableApi
@@ -5,8 +7,9 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
-import android.content.Context
import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
@@ -22,8 +25,10 @@ import android.support.v4.media.session.PlaybackStateCompat
import androidx.media.MediaBrowserServiceCompat
import android.util.Log
import androidx.core.app.NotificationCompat
+import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
+import androidx.media3.common.Player.PositionInfo
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.common.PlaybackException
import com.melodee.autoplayer.R
@@ -37,6 +42,7 @@ import com.melodee.autoplayer.data.api.NetworkModule
import com.melodee.autoplayer.MelodeeApplication
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.StateFlow
import android.provider.MediaStore
@@ -56,6 +62,8 @@ class MusicService : MediaBrowserServiceCompat() {
private var remotePlaylistId: String? = null
private var nextPlaylistPage: Int = 1
private var hasMorePlaylistPages: Boolean = false
+ private var remotePlaylistPageSize: Int = DEFAULT_PLAYLIST_PAGE_SIZE
+ private var nextPlaylistPageFetch: Deferred? = null
private val paginationPrefetchThreshold = 3 // when within 3 songs of end, prefetch next page
// Audio focus management
@@ -103,8 +111,12 @@ class MusicService : MediaBrowserServiceCompat() {
const val ACTION_CLEAR_QUEUE = "com.melodee.autoplayer.ACTION_CLEAR_QUEUE"
const val EXTRA_SONG = "com.melodee.autoplayer.EXTRA_SONG"
const val EXTRA_POSITION = "com.melodee.autoplayer.EXTRA_POSITION"
- const val EXTRA_PLAYLIST = "com.melodee.autoplayer.EXTRA_PLAYLIST"
- const val EXTRA_SEARCH_RESULTS = "com.melodee.autoplayer.EXTRA_SEARCH_RESULTS"
+ const val EXTRA_PLAYLIST_PAGE_SIZE = "com.melodee.autoplayer.EXTRA_PLAYLIST_PAGE_SIZE"
+ const val EXTRA_START_INDEX = "START_INDEX"
+ const val EXTRA_PLAYLIST_ID = "PLAYLIST_ID"
+ const val EXTRA_START_SONG_ID = "START_SONG_ID"
+
+ private const val DEFAULT_PLAYLIST_PAGE_SIZE = 50
}
inner class MusicBinder : Binder() {
@@ -188,13 +200,15 @@ class MusicService : MediaBrowserServiceCompat() {
): BrowserRoot? {
Log.d("MusicService", "onGetRoot called for package: $clientPackageName, uid: $clientUid")
Log.d("MusicService", "Root hints: $rootHints")
+
+ if (!isAllowedBrowserClient(clientPackageName, clientUid)) {
+ Log.w("MusicService", "Rejecting browser client: package=$clientPackageName uid=$clientUid")
+ return null
+ }
// Check if the client is Android Auto
val isAndroidAuto = rootHints?.getBoolean("android.service.media.extra.RECENT") == true ||
- clientPackageName == "com.google.android.projection.gearhead" ||
- clientPackageName == "com.google.android.gms" ||
- clientPackageName.contains("android.auto") ||
- clientPackageName.contains("gearhead")
+ AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName(clientPackageName)
Log.d("MusicService", "Is Android Auto client: $isAndroidAuto (package: $clientPackageName)")
@@ -223,6 +237,33 @@ class MusicService : MediaBrowserServiceCompat() {
return BrowserRoot(MEDIA_ROOT_ID, rootExtras)
}
+ private fun isAllowedBrowserClient(clientPackageName: String, clientUid: Int): Boolean {
+ val packagesForUid = packageManager.getPackagesForUid(clientUid)
+ if (packagesForUid?.contains(clientPackageName) != true) {
+ Log.w("MusicService", "Browser client package does not match uid: package=$clientPackageName uid=$clientUid")
+ return false
+ }
+
+ if (clientUid == applicationInfo.uid || clientPackageName == packageName) {
+ return true
+ }
+
+ if (AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName(clientPackageName)) {
+ return true
+ }
+
+ val clientInfo = try {
+ @Suppress("DEPRECATION")
+ packageManager.getApplicationInfo(clientPackageName, 0)
+ } catch (_: PackageManager.NameNotFoundException) {
+ Log.w("MusicService", "Browser client package not found: $clientPackageName")
+ return false
+ }
+
+ val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
+ return clientInfo.flags and systemFlags != 0
+ }
+
override fun onLoadChildren(
parentId: String,
result: Result>
@@ -288,7 +329,7 @@ class MusicService : MediaBrowserServiceCompat() {
.setMediaId(MEDIA_PLAYLISTS_ID)
.setTitle("Playlists")
.setSubtitle("Browse your playlists")
- .setIconUri(android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_playlist_music}"))
+ .setIconUri("android.resource://$packageName/${R.drawable.ic_playlist_music}".toUri())
.build(),
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
)
@@ -301,7 +342,7 @@ class MusicService : MediaBrowserServiceCompat() {
.setMediaId(MEDIA_QUEUE_ID)
.setTitle("Current Queue")
.setSubtitle("Currently playing songs")
- .setIconUri(android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_library_music}"))
+ .setIconUri("android.resource://$packageName/${R.drawable.ic_library_music}".toUri())
.build(),
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
)
@@ -350,10 +391,8 @@ class MusicService : MediaBrowserServiceCompat() {
.setSubtitle("${playlist.songCount} songs")
.setDescription(playlist.description)
.setIconUri(
- android.net.Uri.parse(
- playlist.imageUrl.takeIf { it.isNotBlank() }
- ?: "android.resource://$packageName/${R.drawable.ic_playlist_music}"
- )
+ (playlist.imageUrl.takeIf { it.isNotBlank() }
+ ?: "android.resource://$packageName/${R.drawable.ic_playlist_music}").toUri()
)
.build(),
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
@@ -541,10 +580,14 @@ class MusicService : MediaBrowserServiceCompat() {
}
}
- private suspend fun fetchPlaylistSongs(playlistId: String): SongPagedResponse {
+ private suspend fun fetchPlaylistSongs(
+ playlistId: String,
+ page: Int = 1,
+ pageSize: Int = DEFAULT_PLAYLIST_PAGE_SIZE
+ ): SongPagedResponse {
return withContext(Dispatchers.IO) {
try {
- Log.d("MusicService", "Fetching songs for playlist: $playlistId")
+ Log.d("MusicService", "Fetching songs for playlist: $playlistId, page=$page, pageSize=$pageSize")
// Verify authentication before making API call
if (!verifyAuthentication()) {
@@ -553,7 +596,7 @@ class MusicService : MediaBrowserServiceCompat() {
}
val musicApi = NetworkModule.getMusicApi()
- val songsResponse = musicApi.getPlaylistSongs(playlistId, 1, 100) // Get first 100 songs
+ val songsResponse = musicApi.getPlaylistSongs(playlistId, page, pageSize)
Log.d("MusicService", "API returned ${songsResponse.data.size} songs for playlist $playlistId")
@@ -768,7 +811,7 @@ class MusicService : MediaBrowserServiceCompat() {
.setTitle(song.title)
.setSubtitle(song.artist.name)
.setDescription(song.album.name)
- .setIconUri(android.net.Uri.parse(song.thumbnailUrl))
+ .setIconUri(song.thumbnailUrl.toUri())
// Add context extras
val extras = Bundle()
@@ -802,67 +845,29 @@ class MusicService : MediaBrowserServiceCompat() {
}
Log.d("MusicService", "Received play command for song: ${song?.title}")
if (song != null) {
- // Set context to single song if no queue is set
- if (queueManager().getQueueSize() == 0) {
- // Context set by playbackManager.setQueue call
- }
- queueManager().addToQueue(song)
- playlistManager().playSong(song)
-
- // Notify Android Auto that the queue has changed
- notifyQueueChanged()
-
- playSong(song)
+ playSingleSong(song)
} else {
Log.e("MusicService", "Received null song in intent")
}
}
ACTION_SET_PLAYLIST -> {
- val songs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableArrayListExtra(EXTRA_PLAYLIST, Song::class.java)
+ val playlistId = intent.getStringExtra(EXTRA_PLAYLIST_ID)
+ val startIndex = intent.getIntExtra(EXTRA_START_INDEX, 0)
+ val startSongId = intent.getStringExtra(EXTRA_START_SONG_ID)
+ val pageSize = intent.getIntExtra(EXTRA_PLAYLIST_PAGE_SIZE, DEFAULT_PLAYLIST_PAGE_SIZE)
+ .coerceAtLeast(1)
+ if (playlistId.isNullOrBlank()) {
+ Log.w("MusicService", "ACTION_SET_PLAYLIST ignored because playlist id is missing")
} else {
- @Suppress("DEPRECATION")
- intent.getParcelableArrayListExtra(EXTRA_PLAYLIST)
- }
- val startIndex = intent.getIntExtra("START_INDEX", 0)
- // Capture playlist pagination context if provided
- remotePlaylistId = intent.getStringExtra("PLAYLIST_ID")
- nextPlaylistPage = intent.getIntExtra("NEXT_PAGE", 1)
- hasMorePlaylistPages = intent.getBooleanExtra("HAS_MORE", false)
- if (songs != null) {
- // Context set by playbackManager.setQueue call
- queueManager().setQueue(songs, startIndex)
- playlistManager().setPlaylist(songs, startIndex)
-
- // Notify Android Auto that the queue has changed
- notifyQueueChanged()
-
- if (songs.isNotEmpty() && startIndex < songs.size) {
- playSong(songs[startIndex])
- // Proactively prefetch next page if we're near the end of this page
- maybePrefetchNextPageIfNeeded()
- }
+ loadPlaylistReferenceAndPlay(playlistId, startIndex, startSongId, pageSize)
}
}
ACTION_SET_SEARCH_RESULTS -> {
- val songs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableArrayListExtra(EXTRA_SEARCH_RESULTS, Song::class.java)
+ val startIndex = intent.getIntExtra(EXTRA_START_INDEX, 0)
+ if (searchResultsCache.isNotEmpty()) {
+ playSearchQueue(searchResultsCache, startIndex)
} else {
- @Suppress("DEPRECATION")
- intent.getParcelableArrayListExtra(EXTRA_SEARCH_RESULTS)
- }
- val startIndex = intent.getIntExtra("START_INDEX", 0)
- if (songs != null) {
- // Context set by playbackManager.setQueue call
- queueManager().setQueue(songs, startIndex)
- playlistManager().setPlaylist(songs, startIndex)
-
- // Notify Android Auto that the queue has changed
- notifyQueueChanged()
-
- if (songs.isNotEmpty() && startIndex < songs.size) {
- playSong(songs[startIndex])
- }
+ Log.w("MusicService", "ACTION_SET_SEARCH_RESULTS ignored because no service-side search queue is cached")
}
}
ACTION_PAUSE -> {
@@ -911,6 +916,91 @@ class MusicService : MediaBrowserServiceCompat() {
return START_STICKY
}
+ fun playSingleSong(song: Song) {
+ clearRemotePlaylistPaging()
+ playbackManager.setQueue(listOf(song), 0, MusicPlaybackManager.PlaybackContext.SINGLE_SONG)
+ notifyQueueChanged()
+ playSong(song)
+ }
+
+ fun playPlaylistQueue(
+ songs: List,
+ startIndex: Int = 0,
+ playlistId: String? = null,
+ nextPage: Int = 1,
+ hasMore: Boolean = false,
+ pageSize: Int = DEFAULT_PLAYLIST_PAGE_SIZE
+ ) {
+ remotePlaylistId = playlistId
+ nextPlaylistPage = nextPage
+ hasMorePlaylistPages = hasMore && !playlistId.isNullOrBlank()
+ remotePlaylistPageSize = pageSize.coerceAtLeast(1)
+ cancelNextPlaylistPageFetch()
+
+ playbackManager.setQueue(songs, startIndex, MusicPlaybackManager.PlaybackContext.PLAYLIST)
+ notifyQueueChanged()
+
+ if (songs.isNotEmpty() && startIndex in songs.indices) {
+ playSong(songs[startIndex])
+ maybePrefetchNextPageIfNeeded()
+ }
+ }
+
+ fun playSearchQueue(songs: List, startIndex: Int = 0) {
+ clearRemotePlaylistPaging()
+ searchResultsCache = songs
+ searchResultsCacheTime = System.currentTimeMillis()
+ playbackManager.setQueue(songs, startIndex, MusicPlaybackManager.PlaybackContext.SEARCH)
+ notifyQueueChanged()
+
+ if (songs.isNotEmpty() && startIndex in songs.indices) {
+ playSong(songs[startIndex])
+ }
+ }
+
+ private fun loadPlaylistReferenceAndPlay(
+ playlistId: String,
+ startIndex: Int,
+ startSongId: String?,
+ pageSize: Int
+ ) {
+ serviceScope.launch {
+ try {
+ val response = fetchPlaylistSongs(playlistId, page = 1, pageSize = pageSize)
+ val songs = response.data
+ val resolvedIndex = startSongId
+ ?.let { songId -> songs.indexOfFirst { it.id.toString() == songId } }
+ ?.takeIf { it >= 0 }
+ ?: startIndex.coerceIn(0, (songs.size - 1).coerceAtLeast(0))
+
+ playPlaylistQueue(
+ songs = songs,
+ startIndex = resolvedIndex,
+ playlistId = playlistId,
+ nextPage = response.meta.currentPage + 1,
+ hasMore = response.meta.hasNext,
+ pageSize = response.meta.pageSize
+ )
+ } catch (e: Exception) {
+ Log.e("MusicService", "Failed to load playlist reference $playlistId", e)
+ }
+ }
+ }
+
+ private fun clearRemotePlaylistPaging() {
+ remotePlaylistId = null
+ nextPlaylistPage = 1
+ hasMorePlaylistPages = false
+ remotePlaylistPageSize = DEFAULT_PLAYLIST_PAGE_SIZE
+ cancelNextPlaylistPageFetch()
+ }
+
+ private fun cancelNextPlaylistPageFetch() {
+ nextPlaylistPageFetch?.cancel()
+ nextPlaylistPageFetch = null
+ isFetchingNextPlaylistPage = false
+ }
+
override fun onBind(intent: Intent): IBinder? {
return if (intent.action == "android.media.browse.MediaBrowserService") {
super.onBind(intent)
@@ -943,8 +1033,6 @@ class MusicService : MediaBrowserServiceCompat() {
playbackManager.release()
player = null
- // Clear on-disk media cache on service shutdown
- MediaCache.clearCache(applicationContext)
super.onDestroy()
}
@@ -959,7 +1047,7 @@ class MusicService : MediaBrowserServiceCompat() {
setShowBadge(false)
}
- val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
@@ -1209,8 +1297,8 @@ class MusicService : MediaBrowserServiceCompat() {
}
override fun onPositionDiscontinuity(
- oldPosition: androidx.media3.common.Player.PositionInfo,
- newPosition: androidx.media3.common.Player.PositionInfo,
+ oldPosition: PositionInfo,
+ newPosition: PositionInfo,
reason: Int
) {
// Update scrobble manager with current position for seeking
@@ -1446,8 +1534,6 @@ class MusicService : MediaBrowserServiceCompat() {
val nextSong = queueManager().skipToNext()
if (nextSong != null) {
Log.d("MusicService", "Playing next song: ${nextSong.title}")
- // Update playlist manager to keep them synchronized
- playlistManager().playSong(nextSong)
playSong(nextSong)
Log.d("MusicService", "Successfully started playing next song: ${nextSong.title}")
// Proactively prefetch next page if we're nearing end of current queue page
@@ -1455,12 +1541,10 @@ class MusicService : MediaBrowserServiceCompat() {
} else {
Log.d("MusicService", "No next song available in current queue")
// Attempt to load the next page of the playlist if available
- if (getCurrentPlaybackContext() == PlaybackContext.PLAYLIST && hasMorePlaylistPages && remotePlaylistId != null && !isFetchingNextPlaylistPage) {
- Log.d("MusicService", "Fetching next playlist page: $nextPlaylistPage for $remotePlaylistId")
- isFetchingNextPlaylistPage = true
+ if (getCurrentPlaybackContext() == PlaybackContext.PLAYLIST && hasMorePlaylistPages && remotePlaylistId != null) {
+ Log.d("MusicService", "Queue boundary reached; waiting for playlist page $nextPlaylistPage for $remotePlaylistId")
serviceScope.launch {
- val appended = fetchAndAppendNextPlaylistPage()
- isFetchingNextPlaylistPage = false
+ val appended = startNextPlaylistPageFetch()?.await() == true
if (appended) {
// Try skipping again now that queue grew
withContext(Dispatchers.Main) { skipToNext() }
@@ -1488,8 +1572,10 @@ class MusicService : MediaBrowserServiceCompat() {
if (!hasMorePlaylistPages) return false
return try {
val musicApi = NetworkModule.getMusicApi()
- Log.d("MusicService", "Requesting playlist page $nextPlaylistPage for $playlistId")
- val response = withContext(Dispatchers.IO) { musicApi.getPlaylistSongs(playlistId, nextPlaylistPage) }
+ Log.d("MusicService", "Requesting playlist page $nextPlaylistPage for $playlistId with pageSize=$remotePlaylistPageSize")
+ val response = withContext(Dispatchers.IO) {
+ musicApi.getPlaylistSongs(playlistId, nextPlaylistPage, remotePlaylistPageSize)
+ }
val newSongs = response.data
Log.d("MusicService", "Fetched ${newSongs.size} new songs (hasNext=${response.meta.hasNext})")
if (newSongs.isNotEmpty()) {
@@ -1500,6 +1586,7 @@ class MusicService : MediaBrowserServiceCompat() {
// Update pagination state
hasMorePlaylistPages = response.meta.hasNext
nextPlaylistPage = response.meta.currentPage + 1
+ remotePlaylistPageSize = response.meta.pageSize.coerceAtLeast(1)
// Inform AA clients that queue changed
notifyQueueChanged()
true
@@ -1513,21 +1600,46 @@ class MusicService : MediaBrowserServiceCompat() {
}
}
+ private fun startNextPlaylistPageFetch(): Deferred? {
+ if (!hasMorePlaylistPages || remotePlaylistId == null) return null
+
+ nextPlaylistPageFetch?.takeIf { it.isActive }?.let { activeFetch ->
+ Log.d("MusicService", "Reusing in-flight playlist page fetch")
+ return activeFetch
+ }
+
+ val deferred = serviceScope.async {
+ isFetchingNextPlaylistPage = true
+ try {
+ fetchAndAppendNextPlaylistPage()
+ } finally {
+ isFetchingNextPlaylistPage = false
+ }
+ }
+ nextPlaylistPageFetch = deferred
+ deferred.invokeOnCompletion {
+ if (nextPlaylistPageFetch == deferred) {
+ nextPlaylistPageFetch = null
+ }
+ }
+ return deferred
+ }
+
private fun maybePrefetchNextPageIfNeeded() {
try {
if (getCurrentPlaybackContext() != PlaybackContext.PLAYLIST) return
- if (!hasMorePlaylistPages || remotePlaylistId == null || isFetchingNextPlaylistPage) return
+ if (!hasMorePlaylistPages || remotePlaylistId == null) return
val size = queueManager().currentQueue.value.size
val idx = queueManager().currentIndex.value
val remaining = size - idx - 1
if (remaining <= paginationPrefetchThreshold) {
Log.d("MusicService", "Near end of queue page (remaining=$remaining). Prefetching page $nextPlaylistPage...")
- isFetchingNextPlaylistPage = true
- serviceScope.launch {
- val appended = fetchAndAppendNextPlaylistPage()
- isFetchingNextPlaylistPage = false
- if (appended) {
- Log.d("MusicService", "Next page appended in advance; queue size now ${queueManager().currentQueue.value.size}")
+ val fetch = startNextPlaylistPageFetch()
+ if (fetch != null) {
+ serviceScope.launch {
+ if (fetch.await()) {
+ Log.d("MusicService", "Next page appended in advance; queue size now ${queueManager().currentQueue.value.size}")
+ }
}
}
}
@@ -1543,8 +1655,6 @@ class MusicService : MediaBrowserServiceCompat() {
val previousSong = queueManager().skipToPrevious()
if (previousSong != null) {
Log.d("MusicService", "Playing previous song: ${previousSong.title}")
- // Update playlist manager to keep them synchronized
- playlistManager().playSong(previousSong)
playSong(previousSong)
Log.d("MusicService", "Successfully started playing previous song: ${previousSong.title}")
} else {
@@ -1553,21 +1663,27 @@ class MusicService : MediaBrowserServiceCompat() {
}
// Public methods for UI binding
- fun pause() {
- Log.d("MusicService", "Pause called")
- player?.pause()
- updateMediaSessionPlaybackState()
- }
+ fun isPlayingFlow(): StateFlow = playbackManager.isPlaying
- fun resume() {
- Log.d("MusicService", "Resume called")
- player?.play()
- updateMediaSessionPlaybackState()
+ fun currentPositionFlow(): StateFlow = playbackManager.currentPosition
+
+ fun currentDurationFlow(): StateFlow = playbackManager.currentDuration
+
+ fun currentSongFlow(): StateFlow = playbackManager.currentSong
+
+ fun currentPlaybackContextFlow(): kotlinx.coroutines.flow.Flow {
+ return playbackManager.playbackContext.map { context ->
+ when (context) {
+ MusicPlaybackManager.PlaybackContext.PLAYLIST -> PlaybackContext.PLAYLIST
+ MusicPlaybackManager.PlaybackContext.SEARCH -> PlaybackContext.SEARCH
+ MusicPlaybackManager.PlaybackContext.SINGLE_SONG -> PlaybackContext.SINGLE_SONG
+ }
+ }
}
- fun stop() {
- Log.d("MusicService", "Stop called")
- player?.stop()
+ fun pause() {
+ Log.d("MusicService", "Pause called")
+ player?.pause()
updateMediaSessionPlaybackState()
}
@@ -1596,10 +1712,6 @@ class MusicService : MediaBrowserServiceCompat() {
}
}
- // Removed duplicate getters - using adapters at bottom of file
-
- fun getCurrentSong(): Song? = currentSong
-
fun getCurrentPlaybackContext(): PlaybackContext = when (playbackManager.playbackContext.value) {
MusicPlaybackManager.PlaybackContext.PLAYLIST -> PlaybackContext.PLAYLIST
MusicPlaybackManager.PlaybackContext.SEARCH -> PlaybackContext.SEARCH
@@ -1624,6 +1736,10 @@ class MusicService : MediaBrowserServiceCompat() {
remotePlaylistId = null
nextPlaylistPage = 1
hasMorePlaylistPages = false
+ remotePlaylistPageSize = DEFAULT_PLAYLIST_PAGE_SIZE
+ nextPlaylistPageFetch?.cancel()
+ nextPlaylistPageFetch = null
+ isFetchingNextPlaylistPage = false
// Clear search cache
searchResultsCache = emptyList()
@@ -1730,7 +1846,10 @@ class MusicService : MediaBrowserServiceCompat() {
Log.d("MusicService", "MediaSession onSetShuffleMode: $shuffleMode")
when (shuffleMode) {
PlaybackStateCompat.SHUFFLE_MODE_ALL -> queueManager().setShuffle(true)
+ PlaybackStateCompat.SHUFFLE_MODE_GROUP -> queueManager().setShuffle(true)
PlaybackStateCompat.SHUFFLE_MODE_NONE -> queueManager().setShuffle(false)
+ PlaybackStateCompat.SHUFFLE_MODE_INVALID -> Unit
+ else -> Unit
}
updateMediaSessionPlaybackState()
}
@@ -2000,6 +2119,7 @@ class MusicService : MediaBrowserServiceCompat() {
remotePlaylistId = playlistId
hasMorePlaylistPages = response.meta.hasNext
nextPlaylistPage = response.meta.currentPage + 1
+ remotePlaylistPageSize = response.meta.pageSize.coerceAtLeast(1)
if (playlistSongs.isNotEmpty()) {
Log.i("MusicService", "Fetched ${playlistSongs.size} songs from playlist")
@@ -2107,6 +2227,7 @@ class MusicService : MediaBrowserServiceCompat() {
currentSong?.let { song ->
val currentPos = player?.currentPosition ?: 0
val duration = player?.duration ?: 0
+ playbackManager.updateProgress(currentPos, duration)
if (duration > 0) {
scrobbleManager?.updatePlaybackPosition(song.id.toString(), currentPos, duration)
}
@@ -2137,25 +2258,8 @@ class MusicService : MediaBrowserServiceCompat() {
}
}
- // Debug method - can be called during development
- fun testSearchFunctionality(query: String = "test") {
- Log.i("MusicService", "=== Testing Search Functionality ===")
- serviceScope.launch {
- try {
- val results = performApiSearch(query)
- Log.i("MusicService", "Test search returned ${results.size} results")
- results.forEach { item ->
- val desc = item.description
- Log.i("MusicService", "Result: ${desc.title} - ${desc.subtitle}")
- }
- } catch (e: Exception) {
- Log.e("MusicService", "Test search failed", e)
- }
- }
- }
-
private fun setupAudioManager() {
- audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
Log.d("MusicService", "AudioManager initialized")
}
@@ -2331,40 +2435,18 @@ class MusicService : MediaBrowserServiceCompat() {
Log.i("MusicService", "NetworkModule has token: $hasNetworkToken")
Log.i("MusicService", "Current user: ${currentUser?.username ?: "null"}")
- // If we have network token and user data but AuthenticationManager state is false,
- // try to restore the authentication state
- if (!isAuthenticatedState && hasNetworkToken && currentUser != null) {
- Log.w("MusicService", "Authentication state mismatch detected - attempting to restore")
-
- // Force re-check authentication in AuthenticationManager
- try {
- // Re-initialize the AuthenticationManager state based on stored data
- val settingsManager = SettingsManager(this)
- if (settingsManager.isAuthenticated()) {
- Log.i("MusicService", "Stored authentication data is valid - restoring state")
-
- // Manually trigger authentication restoration
- NetworkModule.setBaseUrl(settingsManager.serverUrl)
- NetworkModule.setAuthToken(settingsManager.authToken)
-
- // Update AuthenticationManager state through saveAuthentication
- authenticationManager.saveAuthentication(
- settingsManager.authToken,
- settingsManager.userId,
- settingsManager.userEmail,
- settingsManager.username,
- settingsManager.serverUrl
- )
-
- Log.i("MusicService", "Authentication state restored successfully")
- return true
- }
- } catch (e: Exception) {
- Log.e("MusicService", "Failed to restore authentication state", e)
+ var isFullyAuthenticated = isAuthenticatedState && hasNetworkToken && currentUser != null
+ if (!isFullyAuthenticated && settingsManager.isAuthenticated()) {
+ Log.w("MusicService", "Authentication state incomplete; restoring stored credentials")
+ val restored = authenticationManager.restoreAuthenticationFromStorage()
+ isFullyAuthenticated = restored &&
+ NetworkModule.isAuthenticated() &&
+ authenticationManager.getCurrentUser() != null
+ if (isFullyAuthenticated && scrobbleManager == null) {
+ initializeScrobbleManager()
}
}
-
- val isFullyAuthenticated = isAuthenticatedState && hasNetworkToken && currentUser != null
+
Log.i("MusicService", "Fully authenticated: $isFullyAuthenticated")
return isFullyAuthenticated
@@ -2507,23 +2589,6 @@ class MusicService : MediaBrowserServiceCompat() {
return finalResult
}
- private fun clearExpiredSearchCache() {
- val cacheExpirationMs = 10 * 60 * 1000L // 10 minutes
- val currentTime = System.currentTimeMillis()
-
- if (searchResultsCache.isNotEmpty() && (currentTime - searchResultsCacheTime) > cacheExpirationMs) {
- Log.d("MusicService", "Search cache expired, clearing it")
- searchResultsCache = emptyList()
- searchResultsCacheTime = 0
- }
- }
-
- private fun clearSearchCache() {
- Log.d("MusicService", "Manually clearing search cache")
- searchResultsCache = emptyList()
- searchResultsCacheTime = 0
- }
-
private fun notifyQueueChanged() {
// Notify Android Auto that the Current Queue content has changed
Log.d("MusicService", "Notifying Android Auto that queue has changed")
@@ -2628,11 +2693,9 @@ class MusicService : MediaBrowserServiceCompat() {
fun clearQueue() = playbackManager.clearQueue()
- fun getQueueSize() = playbackManager.currentQueue.value.size
-
- fun skipToNext() = if (playbackManager.playNext()) playbackManager.currentSong.value else null
+ fun skipToNext() = playbackManager.moveToNext()
- fun skipToPrevious() = if (playbackManager.playPrevious()) playbackManager.currentSong.value else null
+ fun skipToPrevious() = playbackManager.moveToPrevious()
fun toggleShuffle() = playbackManager.toggleShuffle()
@@ -2681,13 +2744,6 @@ class MusicService : MediaBrowserServiceCompat() {
} else false
}
- fun removeFromQueue(song: Song) {
- val currentList = playbackManager.currentQueue.value.toMutableList()
- currentList.remove(song)
- val currentIndex = playbackManager.currentIndex.value
- playbackManager.setQueue(currentList, if (currentIndex >= currentList.size) 0 else currentIndex)
- }
-
fun removeFromQueue(index: Int) {
val currentList = playbackManager.currentQueue.value.toMutableList()
if (index in currentList.indices) {
@@ -2700,7 +2756,6 @@ class MusicService : MediaBrowserServiceCompat() {
inner class PlaylistManagerAdapter {
val currentPlaylist get() = playbackManager.currentQueue
- val currentIndex get() = playbackManager.currentIndex
val currentSong get() = playbackManager.currentSong
fun setPlaylist(songs: List, startIndex: Int = 0) {
diff --git a/src/app/src/main/java/com/melodee/autoplayer/service/ScrobbleManager.kt b/src/app/src/main/java/com/melodee/autoplayer/service/ScrobbleManager.kt
index e91f59b..8f96d96 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/service/ScrobbleManager.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/service/ScrobbleManager.kt
@@ -109,7 +109,6 @@ class ScrobbleManager(
}
}
- @Suppress("DEPRECATION")
private suspend fun scrobbleNowPlaying(tracker: ScrobbleTracker) {
if (tracker.nowPlayingScrobbled) return
@@ -141,8 +140,8 @@ class ScrobbleManager(
}
is ScrobbleResult.Error -> {
Log.e(TAG, "Failed to scrobble 'nowPlaying' for song: ${tracker.song.title}. " +
- "Status: ${result.httpStatus}, Title: ${result.errorResponse.title}, " +
- "Type: ${result.errorResponse.type}, TraceId: ${result.errorResponse.traceId}")
+ "Status: ${result.httpStatus}, Code: ${result.errorResponse.code}, " +
+ "Message: ${result.errorResponse.message}, CorrelationId: ${result.errorResponse.correlationId}")
}
is ScrobbleResult.NetworkError -> {
Log.e(TAG, "Network error scrobbling 'nowPlaying' for song: ${tracker.song.title}", result.exception)
@@ -154,7 +153,6 @@ class ScrobbleManager(
}
}
- @Suppress("DEPRECATION")
private suspend fun scrobblePlayed(tracker: ScrobbleTracker, duration: Long) {
if (tracker.playedScrobbled) return
@@ -188,8 +186,8 @@ class ScrobbleManager(
}
is ScrobbleResult.Error -> {
Log.e(TAG, "Failed to scrobble 'played' for song: ${tracker.song.title}. " +
- "Status: ${result.httpStatus}, Title: ${result.errorResponse.title}, " +
- "Type: ${result.errorResponse.type}, TraceId: ${result.errorResponse.traceId}")
+ "Status: ${result.httpStatus}, Code: ${result.errorResponse.code}, " +
+ "Message: ${result.errorResponse.message}, CorrelationId: ${result.errorResponse.correlationId}")
}
is ScrobbleResult.NetworkError -> {
Log.e(TAG, "Network error scrobbling 'played' for song: ${tracker.song.title}", result.exception)
diff --git a/src/app/src/main/java/com/melodee/autoplayer/util/AuthenticationHelper.kt b/src/app/src/main/java/com/melodee/autoplayer/util/AuthenticationHelper.kt
index 56b14fd..ed1ca9d 100644
--- a/src/app/src/main/java/com/melodee/autoplayer/util/AuthenticationHelper.kt
+++ b/src/app/src/main/java/com/melodee/autoplayer/util/AuthenticationHelper.kt
@@ -49,8 +49,8 @@ class AuthenticationHelper(private val context: Context) {
userEmail = authResponse.user.email,
username = authResponse.user.username,
serverUrl = serverUrl,
- refreshToken = authResponse.refreshToken,
- refreshTokenExpiresAt = authResponse.refreshTokenExpiresAt
+ refreshToken = authResponse.refreshToken.orEmpty(),
+ refreshTokenExpiresAt = authResponse.refreshTokenExpiresAt.orEmpty()
)
Log.d("AuthenticationHelper", "Login successful for: ${authResponse.user.username}")
diff --git a/src/app/src/test/java/com/melodee/autoplayer/api/RetrofitPathTest.kt b/src/app/src/test/java/com/melodee/autoplayer/api/RetrofitPathTest.kt
index 7399f60..eb6c473 100644
--- a/src/app/src/test/java/com/melodee/autoplayer/api/RetrofitPathTest.kt
+++ b/src/app/src/test/java/com/melodee/autoplayer/api/RetrofitPathTest.kt
@@ -2,18 +2,30 @@ package com.melodee.autoplayer.api
import com.google.common.truth.Truth.assertThat
import com.melodee.autoplayer.data.api.MusicApi
+import com.melodee.autoplayer.data.api.ScrobbleApi
+import com.melodee.autoplayer.data.api.ScrobbleRequest
+import com.melodee.autoplayer.data.api.ScrobbleRequestType
+import com.melodee.autoplayer.data.api.toScrobbleResult
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.Path
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
+import com.google.gson.JsonParser
+import com.melodee.autoplayer.data.api.ScrobbleResult
+import com.melodee.autoplayer.domain.model.LoginModel
+import java.lang.reflect.Method
class RetrofitPathTest {
private lateinit var mockWebServer: MockWebServer
- private lateinit var api: MusicApi
+ private lateinit var musicApi: MusicApi
+ private lateinit var scrobbleApi: ScrobbleApi
@Before
fun setup() {
@@ -25,7 +37,8 @@ class RetrofitPathTest {
.addConverterFactory(GsonConverterFactory.create())
.build()
- api = retrofit.create(MusicApi::class.java)
+ musicApi = retrofit.create(MusicApi::class.java)
+ scrobbleApi = retrofit.create(ScrobbleApi::class.java)
}
@After
@@ -33,6 +46,121 @@ class RetrofitPathTest {
mockWebServer.shutdown()
}
+ private fun musicApiMethod(name: String): Method {
+ return MusicApi::class.java.declaredMethods.first { it.name == name }
+ }
+
+ private fun requireGet(method: Method): GET {
+ return method.getAnnotation(GET::class.java)
+ ?: throw AssertionError("Missing @GET on ${method.name}")
+ }
+
+ private fun requirePost(method: Method): POST {
+ return method.getAnnotation(POST::class.java)
+ ?: throw AssertionError("Missing @POST on ${method.name}")
+ }
+
+ private fun requirePath(method: Method, parameterIndex: Int): Path {
+ return method.parameterAnnotations[parameterIndex]
+ .filterIsInstance()
+ .firstOrNull()
+ ?: throw AssertionError("Missing @Path on ${method.name} parameter $parameterIndex")
+ }
+
+ @Test
+ fun `authenticate endpoint uses v1 authenticate path and request body shape`() = runBlocking {
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """
+ {
+ "token": "access",
+ "serverVersion": "1.2.0",
+ "expiresAt": "2024-01-01T00:00:00Z",
+ "user": {
+ "id": "123e4567-e89b-12d3-a456-426614174000",
+ "thumbnailUrl": "",
+ "imageUrl": "",
+ "username": "tester",
+ "email": "tester@example.com",
+ "isAdmin": false,
+ "isEditor": false,
+ "roles": [],
+ "songsPlayed": 0,
+ "artistsLiked": 0,
+ "artistsDisliked": 0,
+ "albumsLiked": 0,
+ "albumsDisliked": 0,
+ "songsLiked": 0,
+ "songsDisliked": 0,
+ "createdAt": "",
+ "updatedAt": ""
+ }
+ }
+ """.trimIndent()
+ )
+ )
+
+ try {
+ val requestBody = LoginModel(userName = "tester", email = "tester@example.com", password = "secret")
+ musicApi.login(requestBody)
+ } catch (_: Exception) {
+ }
+
+ val request = mockWebServer.takeRequest()
+ val requestJson = JsonParser.parseString(request.body.readUtf8()).asJsonObject
+
+ assertThat(request.path).isEqualTo("/api/v1/auth/authenticate")
+ assertThat(requestJson.keySet()).containsExactly("userName", "email", "password")
+ assertThat(requestJson.get("userName").asString).isEqualTo("tester")
+ assertThat(requestJson.get("email").asString).isEqualTo("tester@example.com")
+ }
+
+ @Test
+ fun `refresh-token endpoint uses v1 refresh path and nullable fields are accepted`() = runBlocking {
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """
+ {
+ "token": "access",
+ "serverVersion": "1.2.0",
+ "expiresAt": "2024-01-01T00:00:00Z",
+ "refreshToken": null,
+ "refreshTokenExpiresAt": null,
+ "user": {
+ "id": "123e4567-e89b-12d3-a456-426614174000",
+ "thumbnailUrl": "",
+ "imageUrl": "",
+ "username": "tester",
+ "email": "tester@example.com",
+ "isAdmin": false,
+ "isEditor": false,
+ "roles": [],
+ "songsPlayed": 0,
+ "artistsLiked": 0,
+ "artistsDisliked": 0,
+ "albumsLiked": 0,
+ "albumsDisliked": 0,
+ "songsLiked": 0,
+ "songsDisliked": 0,
+ "createdAt": "",
+ "updatedAt": ""
+ }
+ }
+ """.trimIndent()
+ )
+ )
+
+ try {
+ musicApi.refresh(com.melodee.autoplayer.domain.model.RefreshTokenRequest("refresh-token"))
+ } catch (_: Exception) {
+ }
+
+ val request = mockWebServer.takeRequest()
+
+ assertThat(request.path).isEqualTo("/api/v1/auth/refresh-token")
+ assertThat(request.body.readUtf8()).contains("\"refreshToken\":\"refresh-token\"")
+ }
+
@Test
fun `playlist songs endpoint uses correct path parameter`() = runBlocking {
val playlistId = "abc123-def456-ghi789"
@@ -46,15 +174,115 @@ class RetrofitPathTest {
)
try {
- api.getPlaylistSongs(playlistId, 1, 10)
+ musicApi.getPlaylistSongs(playlistId, 1, 10)
} catch (_: Exception) {
}
val request = mockWebServer.takeRequest()
assertThat(request.path).contains("/api/v1/playlists/$playlistId/songs")
+ assertThat(request.path).contains("page=1")
+ assertThat(request.path).contains("pageSize=10")
assertThat(request.path).doesNotContain("{apiKey}")
- assertThat(request.path).doesNotContain("{id}")
+ }
+
+ @Test
+ fun `playlist songs endpoint uses apiKey path placeholder`() {
+ val method = musicApiMethod("getPlaylistSongs")
+ val get = requireGet(method)
+ val path = requirePath(method, 0)
+
+ assertThat(get.value).isEqualTo("api/v1/playlists/{apiKey}/songs")
+ assertThat(path.value).isEqualTo("apiKey")
+ }
+
+ @Test
+ fun `refresh token endpoint uses v1 body endpoint`() = runBlocking {
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """
+ {
+ "token": "access",
+ "serverVersion": "1.2.0",
+ "expiresAt": "2024-01-01T00:00:00Z",
+ "refreshToken": "refresh",
+ "refreshTokenExpiresAt": "2024-02-01T00:00:00Z",
+ "user": {
+ "id": "123e4567-e89b-12d3-a456-426614174000",
+ "thumbnailUrl": "",
+ "imageUrl": "",
+ "username": "tester",
+ "email": "tester@example.com",
+ "isAdmin": false,
+ "isEditor": false,
+ "roles": [],
+ "songsPlayed": 0,
+ "artistsLiked": 0,
+ "artistsDisliked": 0,
+ "albumsLiked": 0,
+ "albumsDisliked": 0,
+ "songsLiked": 0,
+ "songsDisliked": 0,
+ "createdAt": "",
+ "updatedAt": ""
+ }
+ }
+ """.trimIndent()
+ )
+ )
+
+ try {
+ musicApi.refresh(com.melodee.autoplayer.domain.model.RefreshTokenRequest("refresh-token"))
+ } catch (_: Exception) {
+ }
+
+ val request = mockWebServer.takeRequest()
+
+ assertThat(request.path).isEqualTo("/api/v1/auth/refresh-token")
+ assertThat(request.body.readUtf8()).contains("\"refreshToken\":\"refresh-token\"")
+ }
+
+ @Test
+ fun `user playlists endpoint uses limit query parameter`() = runBlocking {
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """
+ {"meta": {"totalCount": 0, "pageSize": 25, "currentPage": 1, "totalPages": 0, "hasPrevious": false, "hasNext": false}, "data": []}
+ """.trimIndent()
+ )
+ )
+
+ try {
+ musicApi.getPlaylists(1, 25)
+ } catch (_: Exception) {
+ }
+
+ val request = mockWebServer.takeRequest()
+
+ assertThat(request.path).contains("/api/v1/user/playlists?page=1&limit=25")
+ assertThat(request.path).doesNotContain("pageSize")
+ }
+
+ @Test
+ fun `album songs endpoint does not send unsupported pagination query`() = runBlocking {
+ val albumId = "album-uuid-789"
+
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """
+ {"meta": {"totalCount": 0, "pageSize": 50, "currentPage": 1, "totalPages": 0, "hasPrevious": false, "hasNext": false}, "data": []}
+ """.trimIndent()
+ )
+ )
+
+ try {
+ musicApi.getAlbumSongs(albumId)
+ } catch (_: Exception) {
+ }
+
+ val request = mockWebServer.takeRequest()
+
+ assertThat(request.path).isEqualTo("/api/v1/albums/$albumId/songs")
}
@Test
@@ -65,7 +293,7 @@ class RetrofitPathTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200))
try {
- api.favoriteSong(songId, isStarred)
+ musicApi.favoriteSong(songId, isStarred)
} catch (_: Exception) {
}
@@ -77,6 +305,19 @@ class RetrofitPathTest {
assertThat(request.path).doesNotContain("{isStarred}")
}
+ @Test
+ fun `favorite song endpoint uses apiKey placeholder path`() {
+ val method = musicApiMethod("favoriteSong")
+ val post = requirePost(method)
+
+ val pathParamSong = requirePath(method, 0)
+ val pathParamStarred = requirePath(method, 1)
+
+ assertThat(post.value).isEqualTo("api/v1/songs/starred/{apiKey}/{isStarred}")
+ assertThat(pathParamSong.value).isEqualTo("apiKey")
+ assertThat(pathParamStarred.value).isEqualTo("isStarred")
+ }
+
@Test
fun `artist songs endpoint uses id parameter`() = runBlocking {
val artistId = "artist-uuid-456"
@@ -90,7 +331,7 @@ class RetrofitPathTest {
)
try {
- api.getArtistSongs(artistId, 1, 10)
+ musicApi.getArtistSongs(artistId, 1, 10)
} catch (_: Exception) {
}
@@ -98,4 +339,68 @@ class RetrofitPathTest {
assertThat(request.path).contains("/api/v1/artists/$artistId/songs")
}
+
+ @Test
+ fun `scrobble endpoint uses v1 path and request payload shape`() = runBlocking {
+ mockWebServer.enqueue(MockResponse().setResponseCode(204))
+
+ val requestBody = ScrobbleRequest(
+ songId = "song-uuid",
+ playerName = "MelodeePlayer",
+ scrobbleTypeValue = ScrobbleRequestType.PLAYED,
+ timestamp = 1703462400.0,
+ playedDuration = 42.0
+ )
+
+ try {
+ scrobbleApi.scrobble(requestBody)
+ } catch (_: Exception) {
+ }
+
+ val request = mockWebServer.takeRequest()
+ val payload = JsonParser.parseString(request.body.readUtf8()).asJsonObject
+
+ assertThat(request.path).isEqualTo("/api/v1/scrobble")
+ assertThat(payload.get("songId").asString).isEqualTo("song-uuid")
+ assertThat(payload.get("scrobbleType").asString).isEqualTo("played")
+ assertThat(payload.get("scrobbleTypeValue").asInt).isEqualTo(2)
+ assertThat(payload.get("timestamp").isJsonPrimitive).isTrue()
+ assertThat(payload.get("playedDuration").isJsonPrimitive).isTrue()
+ }
+
+ @Test
+ fun `scrobble error payload follows v1 error contract`() = runBlocking {
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(400).setBody(
+ """
+ {
+ "code": "invalid_request",
+ "message": "Invalid scrobble payload",
+ "correlationId": null
+ }
+ """.trimIndent()
+ )
+ )
+
+ val response = try {
+ scrobbleApi.scrobble(
+ ScrobbleRequest(
+ songId = "song-uuid",
+ playerName = "MelodeePlayer",
+ scrobbleTypeValue = ScrobbleRequestType.NOW_PLAYING,
+ timestamp = 1703462400.0,
+ playedDuration = 0.0
+ )
+ )
+ } catch (_: Exception) {
+ throw AssertionError("Scrobble call should not throw; HTTP error should be surfaced via Response")
+ }
+
+ val result = response.toScrobbleResult()
+
+ assertThat((result as? ScrobbleResult.Error)?.errorResponse?.code).isEqualTo("invalid_request")
+ assertThat((result as? ScrobbleResult.Error)?.errorResponse?.message).isEqualTo("Invalid scrobble payload")
+ assertThat((result as? ScrobbleResult.Error)?.errorResponse?.correlationId).isNull()
+ assertThat((result as? ScrobbleResult.Error)?.httpStatus).isEqualTo(400)
+ }
}
diff --git a/src/app/src/test/java/com/melodee/autoplayer/data/NetworkModuleAuthRefreshTest.kt b/src/app/src/test/java/com/melodee/autoplayer/data/NetworkModuleAuthRefreshTest.kt
new file mode 100644
index 0000000..92bbebe
--- /dev/null
+++ b/src/app/src/test/java/com/melodee/autoplayer/data/NetworkModuleAuthRefreshTest.kt
@@ -0,0 +1,92 @@
+package com.melodee.autoplayer.data
+
+import com.google.common.truth.Truth.assertThat
+import com.melodee.autoplayer.data.api.MusicApi
+import com.melodee.autoplayer.data.api.NetworkModule
+import kotlinx.coroutines.runBlocking
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.Assert.assertThrows
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import retrofit2.HttpException
+
+@RunWith(RobolectricTestRunner::class)
+class NetworkModuleAuthRefreshTest {
+ private lateinit var mockWebServer: MockWebServer
+ private lateinit var musicApi: MusicApi
+ private var authFailureCount = 0
+
+ @Before
+ fun setUp() {
+ mockWebServer = MockWebServer()
+ mockWebServer.start()
+
+ NetworkModule.setBaseUrl(mockWebServer.url("/").toString())
+ NetworkModule.setTokens("", "")
+
+ musicApi = NetworkModule.getMusicApi()
+ authFailureCount = 0
+ NetworkModule.setAuthenticationFailureCallback { authFailureCount++ }
+ }
+
+ @After
+ fun tearDown() {
+ NetworkModule.setTokens("", "")
+ mockWebServer.shutdown()
+ NetworkModule.setAuthenticationFailureCallback {}
+ }
+
+ @Test
+ fun `transient refresh failure keeps stored auth tokens and does not clear session`() {
+ NetworkModule.setTokens("access-token", "refresh-token")
+
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(401)
+ )
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(502).setBody("temporary outage")
+ )
+
+ val failure = assertThrows(HttpException::class.java) { runBlocking { musicApi.getCurrentUser() } }
+ assertThat(failure.code()).isEqualTo(401)
+
+ val firstRequest = mockWebServer.takeRequest()
+ val secondRequest = mockWebServer.takeRequest()
+
+ assertThat(firstRequest.path).isEqualTo("/api/v1/user/me")
+ assertThat(secondRequest.path).isEqualTo("/api/v1/auth/refresh-token")
+
+ assertThat(NetworkModule.isAuthenticated()).isTrue()
+ assertThat(authFailureCount).isEqualTo(0)
+ assertThat(NetworkModule.getAuthToken()).isEqualTo("access-token")
+ }
+
+ @Test
+ fun `invalid refresh clears auth and triggers auth failure handling`() {
+ NetworkModule.setTokens("access-token", "refresh-token")
+
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(401)
+ )
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(401).setBody("refresh expired")
+ )
+
+ val failure = assertThrows(HttpException::class.java) { runBlocking { musicApi.getCurrentUser() } }
+ assertThat(failure.code()).isEqualTo(401)
+
+ val firstRequest = mockWebServer.takeRequest()
+ val secondRequest = mockWebServer.takeRequest()
+
+ assertThat(firstRequest.path).isEqualTo("/api/v1/user/me")
+ assertThat(secondRequest.path).isEqualTo("/api/v1/auth/refresh-token")
+
+ assertThat(NetworkModule.isAuthenticated()).isFalse()
+ assertThat(NetworkModule.getAuthToken()).isNull()
+ assertThat(authFailureCount).isEqualTo(1)
+ }
+}
diff --git a/src/app/src/test/java/com/melodee/autoplayer/data/SettingsManagerTest.kt b/src/app/src/test/java/com/melodee/autoplayer/data/SettingsManagerTest.kt
new file mode 100644
index 0000000..7bfc3d7
--- /dev/null
+++ b/src/app/src/test/java/com/melodee/autoplayer/data/SettingsManagerTest.kt
@@ -0,0 +1,102 @@
+package com.melodee.autoplayer.data
+
+import android.content.Context
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SettingsManagerTest {
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = RuntimeEnvironment.getApplication().applicationContext
+ context.getSharedPreferences("MelodeePrefs", Context.MODE_PRIVATE).edit().clear().commit()
+ context.getSharedPreferences("MelodeeSecurePrefs", Context.MODE_PRIVATE).edit().clear().commit()
+ }
+
+ @Test
+ fun saveAuthenticationData_preservesExistingRefreshTokenForSameUserWhenRefreshIsOmitted() {
+ val settings = SettingsManager(context)
+
+ settings.saveAuthenticationData(
+ token = "access-1",
+ userId = "user-1",
+ userEmail = "user@example.com",
+ username = "user",
+ serverUrl = "https://server.example/",
+ refreshToken = "refresh-1",
+ refreshTokenExpiresAt = "2030-01-01T00:00:00Z"
+ )
+
+ settings.saveAuthenticationData(
+ token = "access-2",
+ userId = "user-1",
+ userEmail = "user@example.com",
+ username = "user",
+ serverUrl = "https://server.example/"
+ )
+
+ assertThat(settings.authToken).isEqualTo("access-2")
+ assertThat(settings.refreshToken).isEqualTo("refresh-1")
+ assertThat(settings.refreshTokenExpiresAt).isEqualTo("2030-01-01T00:00:00Z")
+ }
+
+ @Test
+ fun saveAuthenticationData_doesNotPreserveExistingRefreshTokenForDifferentUser() {
+ val settings = SettingsManager(context)
+
+ settings.saveAuthenticationData(
+ token = "access-1",
+ userId = "user-1",
+ userEmail = "user1@example.com",
+ username = "user1",
+ serverUrl = "https://server.example/",
+ refreshToken = "refresh-1",
+ refreshTokenExpiresAt = "2030-01-01T00:00:00Z"
+ )
+
+ settings.saveAuthenticationData(
+ token = "access-2",
+ userId = "user-2",
+ userEmail = "user2@example.com",
+ username = "user2",
+ serverUrl = "https://server.example/"
+ )
+
+ assertThat(settings.authToken).isEqualTo("access-2")
+ assertThat(settings.refreshToken).isEmpty()
+ assertThat(settings.refreshTokenExpiresAt).isEmpty()
+ }
+
+ @Test
+ fun saveAuthenticationData_preservesRefreshExpiryForSameUserWhenRefreshTokenIsOmitted() {
+ val settings = SettingsManager(context)
+
+ settings.saveAuthenticationData(
+ token = "access-1",
+ userId = "user-1",
+ userEmail = "user@example.com",
+ username = "user",
+ serverUrl = "https://server.example/",
+ refreshToken = "refresh-1",
+ refreshTokenExpiresAt = "2030-01-01T00:00:00Z"
+ )
+
+ settings.saveAuthenticationData(
+ token = "access-2",
+ userId = "user-1",
+ userEmail = "user@example.com",
+ username = "user",
+ serverUrl = "https://server.example/"
+ )
+
+ assertThat(settings.authToken).isEqualTo("access-2")
+ assertThat(settings.refreshToken).isEqualTo("refresh-1")
+ assertThat(settings.refreshTokenExpiresAt).isEqualTo("2030-01-01T00:00:00Z")
+ }
+}
diff --git a/src/app/src/test/java/com/melodee/autoplayer/domain/ModelSerializationTest.kt b/src/app/src/test/java/com/melodee/autoplayer/domain/ModelSerializationTest.kt
index 065f4c3..95eb4db 100644
--- a/src/app/src/test/java/com/melodee/autoplayer/domain/ModelSerializationTest.kt
+++ b/src/app/src/test/java/com/melodee/autoplayer/domain/ModelSerializationTest.kt
@@ -12,6 +12,7 @@ import com.melodee.autoplayer.data.api.ScrobbleRequest
import com.melodee.autoplayer.data.api.ScrobbleRequestType
import com.melodee.autoplayer.domain.model.Album
import com.melodee.autoplayer.domain.model.Artist
+import com.melodee.autoplayer.domain.model.AuthenticationResponse
import com.melodee.autoplayer.domain.model.PaginationMetadata
import com.melodee.autoplayer.domain.model.Song
import com.melodee.autoplayer.domain.model.SongPagedResponse
@@ -41,9 +42,9 @@ class ModelSerializationTest {
} else {
UUID.fromString(str)
}
- } catch (e: IllegalArgumentException) {
+ } catch (_: IllegalArgumentException) {
UUID(0, 0)
- } catch (e: IllegalStateException) {
+ } catch (_: IllegalStateException) {
UUID(0, 0)
}
}
@@ -143,6 +144,60 @@ class ModelSerializationTest {
assertThat(restored.genre).isEqualTo("rock")
}
+ @Test
+ fun artist_deserializes_nullable_genres_from_v1_api() {
+ val json = """
+ {
+ "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "thumbnailUrl": "",
+ "imageUrl": "",
+ "name": "Artist",
+ "userStarred": false,
+ "userRating": 0,
+ "albumCount": 0,
+ "songCount": 0,
+ "createdAt": "",
+ "updatedAt": "",
+ "biography": null,
+ "genres": null
+ }
+ """.trimIndent()
+
+ val artist = gson.fromJson(json, Artist::class.java)
+
+ assertThat(artist.genres).isNull()
+ }
+
+ @Test
+ fun artist_genres_deserializes_and_serializes_as_string_array() {
+ val json = """
+ {
+ "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "thumbnailUrl": "",
+ "imageUrl": "",
+ "name": "Artist",
+ "userStarred": false,
+ "userRating": 0,
+ "albumCount": 0,
+ "songCount": 0,
+ "createdAt": "",
+ "updatedAt": "",
+ "genres": ["rock", "jazz"]
+ }
+ """.trimIndent()
+
+ val artist = gson.fromJson(json, Artist::class.java)
+
+ assertThat(artist.genres).containsExactly("rock", "jazz")
+
+ val serialized = JsonParser.parseString(gson.toJson(artist)).asJsonObject
+ val genres = serialized.getAsJsonArray("genres")
+
+ assertThat(genres.size()).isEqualTo(2)
+ assertThat(genres[0].asString).isEqualTo("rock")
+ assertThat(genres[1].asString).isEqualTo("jazz")
+ }
+
@Test
fun paged_response_serializes_with_metadata() {
val meta = PaginationMetadata(
@@ -205,6 +260,43 @@ class ModelSerializationTest {
assertThat(jsonObject.get("scrobbleTypeValue").asInt).isEqualTo(2)
}
+ @Test
+ fun `authentication response preserves nullable refresh token fields`() {
+ val json = """
+ {
+ "token": "access",
+ "serverVersion": "1.2.0",
+ "expiresAt": "2024-01-01T00:00:00Z",
+ "refreshToken": null,
+ "refreshTokenExpiresAt": null,
+ "user": {
+ "id": "123e4567-e89b-12d3-a456-426614174000",
+ "thumbnailUrl": "",
+ "imageUrl": "",
+ "username": "tester",
+ "email": "tester@example.com",
+ "isAdmin": false,
+ "isEditor": false,
+ "roles": [],
+ "songsPlayed": 0,
+ "artistsLiked": 0,
+ "artistsDisliked": 0,
+ "albumsLiked": 0,
+ "albumsDisliked": 0,
+ "songsLiked": 0,
+ "songsDisliked": 0,
+ "createdAt": "",
+ "updatedAt": ""
+ }
+ }
+ """.trimIndent()
+
+ val response = gson.fromJson(json, AuthenticationResponse::class.java)
+
+ assertThat(response.refreshToken).isNull()
+ assertThat(response.refreshTokenExpiresAt).isNull()
+ }
+
@Test
fun song_parcelable_round_trip_preserves_new_fields() {
val artist = Artist(
@@ -262,7 +354,7 @@ class ModelSerializationTest {
song.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
- val restored = Song.CREATOR.createFromParcel(parcel)
+ val restored = Song.createFromParcel(parcel)
parcel.recycle()
assertThat(restored).isEqualTo(song)
diff --git a/src/app/src/test/java/com/melodee/autoplayer/service/MusicServiceAuthRestorationTest.kt b/src/app/src/test/java/com/melodee/autoplayer/service/MusicServiceAuthRestorationTest.kt
new file mode 100644
index 0000000..14a1ed4
--- /dev/null
+++ b/src/app/src/test/java/com/melodee/autoplayer/service/MusicServiceAuthRestorationTest.kt
@@ -0,0 +1,75 @@
+package com.melodee.autoplayer.service
+
+import com.google.common.truth.Truth.assertThat
+import com.melodee.autoplayer.data.AuthenticationManager
+import com.melodee.autoplayer.data.SettingsManager
+import com.melodee.autoplayer.data.api.NetworkModule
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class MusicServiceAuthRestorationTest {
+ private lateinit var settingsManager: SettingsManager
+ private lateinit var authenticationManager: AuthenticationManager
+ private lateinit var context: android.content.Context
+
+ @Before
+ fun setUp() {
+ context = RuntimeEnvironment.getApplication().applicationContext
+ settingsManager = SettingsManager(context)
+ settingsManager.serverUrl = ""
+ settingsManager.authToken = ""
+ settingsManager.refreshToken = ""
+ settingsManager.userId = ""
+ settingsManager.userEmail = ""
+ settingsManager.username = ""
+ settingsManager.userThumbnailUrl = ""
+ settingsManager.userImageUrl = ""
+ settingsManager.refreshTokenExpiresAt = ""
+
+ settingsManager.saveAuthenticationData(
+ token = "service-auth-token",
+ userId = "user-1",
+ userEmail = "user@example.com",
+ username = "User",
+ serverUrl = "https://server.example/",
+ refreshToken = "service-refresh-token",
+ refreshTokenExpiresAt = "2030-01-01T00:00:00Z"
+ )
+
+ authenticationManager = AuthenticationManager(context)
+ NetworkModule.setBaseUrl("https://stale-server/")
+ NetworkModule.clearAuthentication()
+ }
+
+ @After
+ fun tearDown() {
+ NetworkModule.clearAuthentication()
+ }
+
+ @Test
+ fun `ensureAuthentication restores persisted auth from settings without requiring explicit login`() {
+ val service = MusicService()
+ val settingsField = MusicService::class.java.getDeclaredField("settingsManager")
+ settingsField.isAccessible = true
+ settingsField.set(service, settingsManager)
+
+ val authManagerField = MusicService::class.java.getDeclaredField("authenticationManager")
+ authManagerField.isAccessible = true
+ authManagerField.set(service, authenticationManager)
+
+ val ensureAuthMethod = MusicService::class.java.getDeclaredMethod("ensureAuthentication")
+ ensureAuthMethod.isAccessible = true
+
+ val restored = ensureAuthMethod.invoke(service) as Boolean
+
+ assertThat(restored).isTrue()
+ assertThat(NetworkModule.isAuthenticated()).isTrue()
+ assertThat(NetworkModule.getAuthToken()).isEqualTo("service-auth-token")
+ assertThat(NetworkModule.getAuthToken()).isNotEmpty()
+ }
+}
diff --git a/src/app/src/test/java/com/melodee/autoplayer/service/MusicServiceCallerValidationTest.kt b/src/app/src/test/java/com/melodee/autoplayer/service/MusicServiceCallerValidationTest.kt
new file mode 100644
index 0000000..df9a706
--- /dev/null
+++ b/src/app/src/test/java/com/melodee/autoplayer/service/MusicServiceCallerValidationTest.kt
@@ -0,0 +1,20 @@
+package com.melodee.autoplayer.service
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class MusicServiceCallerValidationTest {
+ @Test
+ fun `trusted android auto packages are recognized by exact match`() {
+ assertThat(AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName("com.google.android.projection.gearhead")).isTrue()
+ assertThat(AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName("com.google.android.gms")).isTrue()
+ }
+
+ @Test
+ fun `untrusted package names are not accepted by substring only matches`() {
+ assertThat(AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName("com.example.android.auto")).isFalse()
+ assertThat(AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName("com.malicious.gearhead.proxy")).isFalse()
+ assertThat(AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName("com.google.android.projection.gearhead.debug")).isFalse()
+ assertThat(AndroidAutoBrowserClientValidator.isTrustedAndroidAutoPackageName("com.example.melodee.autoplayer")).isFalse()
+ }
+}
diff --git a/src/app/src/test/java/com/melodee/autoplayer/service/ScrobbleRequestTest.kt b/src/app/src/test/java/com/melodee/autoplayer/service/ScrobbleRequestTest.kt
index c5e9091..55433ae 100644
--- a/src/app/src/test/java/com/melodee/autoplayer/service/ScrobbleRequestTest.kt
+++ b/src/app/src/test/java/com/melodee/autoplayer/service/ScrobbleRequestTest.kt
@@ -30,6 +30,7 @@ class ScrobbleRequestTest {
assertThat(jsonObject.get("timestamp").asDouble).isEqualTo(expectedSeconds)
assertThat(jsonObject.get("timestamp").asDouble).isLessThan(2000000000.0)
+ assertThat(jsonObject.get("scrobbleType").asString).isEqualTo("nowPlaying")
}
@Test
@@ -51,6 +52,7 @@ class ScrobbleRequestTest {
val jsonObject = JsonParser.parseString(json).asJsonObject
assertThat(jsonObject.get("playedDuration").asDouble).isEqualTo(expectedSeconds)
+ assertThat(jsonObject.get("scrobbleType").asString).isEqualTo("played")
}
@Test
diff --git a/src/benchmark/build.gradle.kts b/src/benchmark/build.gradle.kts
index b59f8a6..9b70be3 100644
--- a/src/benchmark/build.gradle.kts
+++ b/src/benchmark/build.gradle.kts
@@ -1,15 +1,14 @@
plugins {
id("com.android.test")
- id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.melodee.autoplayer.benchmark"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
minSdk = 23
- targetSdk = 35
+ targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments += mapOf(
@@ -35,8 +34,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions { jvmTarget = "17" }
-
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
@@ -47,11 +44,25 @@ android {
excludes += "/META-INF/NOTICE*"
excludes += "/META-INF/DEPENDENCIES"
}
+ jniLibs {
+ keepDebugSymbols += setOf(
+ "**/libandroidx.graphics.path.so",
+ "**/libbenchmarkNative.so",
+ "**/libdatastore_shared_counter.so",
+ "**/libtracing_perfetto.so"
+ )
+ }
+ }
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
- implementation("androidx.test.ext:junit:1.2.1")
- implementation("androidx.test.espresso:espresso-core:3.6.1")
- implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4")
+ implementation("androidx.test.ext:junit:1.3.0")
+ implementation("androidx.test.espresso:espresso-core:3.7.0")
+ implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
}
diff --git a/src/build.gradle.kts b/src/build.gradle.kts
index bf598b4..332abc2 100644
--- a/src/build.gradle.kts
+++ b/src/build.gradle.kts
@@ -1,13 +1,13 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id("com.android.application") version "8.12.0" apply false
- id("com.android.test") version "8.12.0" apply false
- id("org.jetbrains.kotlin.android") version "1.9.20" apply false
- id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.20" apply false
- id("com.google.gms.google-services") version "4.4.2" apply false
- id("com.google.firebase.crashlytics") version "3.0.2" apply false
+ id("com.android.application") version "9.2.1" apply false
+ id("com.android.test") version "9.2.1" apply false
+ id("org.jetbrains.kotlin.plugin.parcelize") version "2.3.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
+ id("com.google.gms.google-services") version "4.4.4" apply false
+ id("com.google.firebase.crashlytics") version "3.0.7" apply false
}
tasks.register("clean", Delete::class) {
delete(layout.buildDirectory)
-}
\ No newline at end of file
+}
diff --git a/src/gradle.properties b/src/gradle.properties
index bcbe7fe..8ee3813 100644
--- a/src/gradle.properties
+++ b/src/gradle.properties
@@ -7,7 +7,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
@@ -27,10 +27,7 @@ kotlin.code.style=official
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
-# Suppress unsupported compileSdk warnings for newer Android versions
-android.suppressUnsupportedCompileSdk=34,35
-
-# Enable configuration cache for better build performance (with updated AGP 8.2.2)
+# Enable configuration cache for better build performance
org.gradle.configuration-cache=true
# Enable build cache for faster builds
@@ -39,11 +36,5 @@ org.gradle.caching=true
# Use stable configuration cache with warnings
org.gradle.configuration-cache.problems=warn
-# Enable Gradle 9.0 compatibility features
-org.gradle.dependency.verification=lenient
-
-# Disable deprecated features for Gradle 9.0 compatibility
+# Enable full R8 optimization
android.enableR8.fullMode=true
-
-# JVM compatibility settings
-org.gradle.java.home=/usr/lib/jvm/java-17-openjdk
\ No newline at end of file
diff --git a/src/gradle/gradle-daemon-jvm.properties b/src/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..6c1139e
--- /dev/null
+++ b/src/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainVersion=21
diff --git a/src/gradle/wrapper/gradle-wrapper.jar b/src/gradle/wrapper/gradle-wrapper.jar
index a4b76b9..b1b8ef5 100644
Binary files a/src/gradle/wrapper/gradle-wrapper.jar and b/src/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/src/gradle/wrapper/gradle-wrapper.properties b/src/gradle/wrapper/gradle-wrapper.properties
index 37f853b..df6a6ad 100644
--- a/src/gradle/wrapper/gradle-wrapper.properties
+++ b/src/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
+retries=0
+retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/src/gradlew b/src/gradlew
index f3b75f3..b9bb139 100755
--- a/src/gradlew
+++ b/src/gradlew
@@ -1,7 +1,7 @@
#!/bin/sh
#
-# Copyright © 2015-2021 the original authors.
+# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -205,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/src/gradlew.bat b/src/gradlew.bat
index 9d21a21..24c62d5 100644
--- a/src/gradlew.bat
+++ b/src/gradlew.bat
@@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%