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 logo # 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. [![Android CI](https://github.com/melodee-project/melodee-player/actions/workflows/android.yml/badge.svg)](https://github.com/melodee-project/melodee-player/actions/workflows/android.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) - ![minSdk](https://img.shields.io/badge/minSdk-21-blue) - ![targetSdk](https://img.shields.io/badge/targetSdk-35-blue) + ![minSdk](https://img.shields.io/badge/minSdk-23-blue) + ![targetSdk](https://img.shields.io/badge/targetSdk-36-blue) - 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%