diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..eb4acc23 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,55 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best for the overall community, and not just for ourselves + +Examples of unacceptable behavior include: +* The use of sexualized language or imagery, and unwelcome sexual attention or advances +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at our repository issues page. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +--- + +## 📖 Documentation Navigation + +- [README.md](./README.md) - Main repository overview. +- [CONTRIBUTING.md](./CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) - Behavior and community guidelines (this document). +- [docs/architecture.md](./docs/architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./docs/setup.md) - Environment installation checklist. +- [docs/development.md](./docs/development.md) - Development commands and workflows. +- [docs/configuration.md](./docs/configuration.md) - App parameters and credentials reference. +- [docs/api.md](./docs/api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./docs/deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./docs/troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./docs/ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..76558d21 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,231 @@ +# Contributing to ARVIO + +Thank you for your interest in contributing to ARVIO! We welcome bug fixes, documentation, feature requests, and code improvements. + +--- + +## 1. Prerequisites + +Before you start contributing, ensure you have the following installed: +- **Java Development Kit (JDK):** Version 17 is required. We recommend Azul Zulu 17 or Eclipse Temurin. +- **Android SDK:** Command Line tools or Android Studio with SDK API Level 35. +- **Node.js:** Node.js (v18+) is required for running code-signing scripts and executing GitHub actions. +- **Git:** Installed and configured with your name and email. + +--- + +## 2. Local Environment Setup + +1. Fork the ARVIO repository to your own GitHub account. +2. Clone your fork locally: + ```bash + git clone https://github.com/YOUR_USERNAME/ARVIO.git + cd ARVIO + ``` +3. Set the upstream remote to point to the main repository: + ```bash + git remote add upstream https://github.com/ProdigyV21/ARVIO.git + ``` +4. Verify your remote configuration: + ```bash + git remote -v + ``` + +--- + +## 3. Installation Steps + +1. Copy the default secrets file to create your local `secrets.properties`: + ```bash + cp secrets.defaults.properties secrets.properties + ``` +2. (Optional) For signed release builds, copy the keystore template: + ```bash + cp keystore.properties.template keystore.properties + ``` +3. Sync Gradle dependencies: + - On Android Studio, click **File > Sync Project with Gradle Files**. + - Or compile the project from the command line: + ```bash + ./gradlew assemblePlayDebug + ``` + +--- + +## 4. Running Tests + +Testing is required for all pull requests. + +### Running Local JUnit Unit Tests +Run unit tests for the main app flavor from the command line: +```bash +./gradlew :app:testPlayDebugUnitTest +``` + +### Running Instrumented/UI Tests (Requires Emulator or Connected Device) +```bash +./gradlew :app:connectedPlayDebugAndroidTest +``` + +--- + +## 5. Development Workflow + +We use a feature branch workflow. Here is an example of creating a new feature: + +1. Sync your local `main` branch with the upstream repository: + ```bash + git checkout main + git pull upstream main + ``` +2. Create a new branch for your feature: + ```bash + git checkout -b feature/your-feature-name + ``` +3. Write your code and run formatting checks. +4. Stage and commit your changes: + ```bash + git add . + git commit -m "feat: add support for new media types" + ``` +5. Push to your fork: + ```bash + git push origin feature/your-feature-name + ``` +6. Open a Pull Request from your branch to ARVIO's `main` branch. + +--- + +## 6. Branch Naming Conventions + +Branches should follow these naming patterns: +- **Features:** `feature/short-description` +- **Bug Fixes:** `bugfix/short-description` +- **Documentation:** `docs/short-description` +- **Performance Updates:** `perf/short-description` +- **Refactoring:** `refactor/short-description` + +--- + +## 7. Commit Message Conventions + +We follow the Conventional Commits specification. Commit messages should be structured as follows: + +`(): ` + +### Allowed Types +- `feat`: A new feature +- `fix`: A bug fix +- `docs`: Documentation changes +- `style`: Formatting, missing semi-colons, etc. (no production code changes) +- `refactor`: Refactoring production code +- `perf`: Code changes that improve performance +- `test`: Adding missing tests or correcting existing tests +- `chore`: Build tasks, package manager configs, etc. + +### Examples +- `feat(player): add subtitle offset settings` +- `fix(iptv): resolve race condition in category EPG loading` +- `docs(readme): update build variant documentation` + +--- + +## 8. Pull Request Process + +1. Ensure all local unit tests pass before submitting. +2. Update the [CHANGELOG.md](./CHANGELOG.md) with your changes under the `[Unreleased]` section. +3. Open a Pull Request on GitHub. +4. Link the PR to the relevant issue in the description (e.g. `Closes #123`). +5. Ensure the CI build check passes. If it fails, fix the issues in your branch and push updates. + +--- + +## 9. Code Review Expectations + +All PRs must be reviewed and approved by at least one maintainer. Reviewers will check for: +- Maintainability, readability, and naming conventions. +- Test coverage for new logic. +- Separation of concerns and proper architectural patterns. +- Absence of credentials or hardcoded keys. + +--- + +## 10. Linting and Formatting Requirements + +We use **Detekt** for static analysis of Kotlin source files. To run Detekt checks locally, execute: +```bash +./gradlew detekt +``` +Ensure your code does not introduce new lint violations. If necessary, you can run `./gradlew detektBaseline` to baseline current issues or correct your code formatting. + +--- + +## 11. Documentation Contribution Standards + +- Every new feature should be accompanied by relevant documentation in `docs/` or an update to [README.md](./README.md). +- Keep explanations clear and concise. +- Use relative Markdown paths with absolute links for repository resources to maintain interlinking consistency. +- Maintain the navigation index at the bottom of all refactored markdown documents. + +--- + +## 12. Issue Reporting Guidelines + +If you find a bug, please open an issue containing: +- A descriptive title. +- Steps to reproduce the issue. +- Expected vs actual behavior. +- App version/build variant and device type (Android TV model, Fire TV, Mobile, etc.). +- Relevant logcat traces or crash logs. + +--- + +## 13. First-Time Contributor Guide + +If this is your first time contributing to an open-source project: +1. Search for issues labeled `good first issue`. +2. Do not hesitate to ask questions in the issue discussion thread if you need help. +3. Check the [docs/setup.md](./docs/setup.md) guide for help setting up your machine. + +--- + +## 14. Debugging Tips + +- **Logcat Logs:** Filter Logcat in Android Studio with `package:mine` or tag `ARVIO` to see output from [AppLogger.kt](./app/src/main/kotlin/com/arflix/tv/util/AppLogger.kt). +- **Network Inspector:** Use the Network Profiler in Android Studio to monitor OkHttp requests routed through [OkHttpProvider.kt](./app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt). +- **Supabase Logs:** If debugging cloud sync, check the Supabase CLI logs: + ```bash + supabase functions serve + ``` + +--- + +## 15. Common Development Pitfalls + +- **Committing Secrets:** Never commit `secrets.properties` or `keystore.properties` to version control. They are git-ignored by design. +- **Compose Recomposition Loops:** Be careful when using mutable states inside Compose columns/rows. Always check stability criteria and register stable types in `app/compose_stability_config.conf`. +- **D-Pad Focus Skips:** Ensure focus bounds are properly specified using focus modifiers for Android TV remotes to avoid trapping focus. + +--- + +## 🛠 Recommended Tooling and Extensions + +- **Android Studio (IDE):** JetBrains Kotlin plugin, Detekt IntelliJ plugin. +- **VS Code / Cursor (for Deno/Edge functions):** Deno Extension, Tailwind CSS IntelliSense (if editing netlify site). +- **Postman / Hoppscotch:** For querying Supabase edge function endpoints. + +--- + +## 📖 Documentation Navigation + +- [README.md](./README.md) - Main repository overview. +- [CONTRIBUTING.md](./CONTRIBUTING.md) - Guidelines for contributing code (this document). +- [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./docs/architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./docs/setup.md) - Environment installation checklist. +- [docs/development.md](./docs/development.md) - Development commands and workflows. +- [docs/configuration.md](./docs/configuration.md) - App parameters and credentials reference. +- [docs/api.md](./docs/api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./docs/deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./docs/troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./docs/ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/README.md b/README.md index ec725d4a..5308fb25 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,233 @@ -# ARVIO - -ARVIO is an Android media hub for TV, phone, and tablet form factors. This repository is maintained as a source-code and development mirror for the Android application. - -The app provides a media browser, player shell, profile support, optional cloud sync, IPTV playlist support, catalog configuration, home-server integrations, and integrations with user-configured sources. ARVIO does not host, store, sell, or distribute movies, series, live TV channels, playlists, streams, or other third-party media. - -## Repository Purpose - -This GitHub repository is for: - -- Source code review and development -- Issue investigation and technical discussion -- Build documentation -- License and privacy documentation -- Contribution review - -It is not intended as an advertising page, download landing page, or content distribution repository. - -## Features - -- Android TV, Fire TV, phone, and tablet UI -- TMDB-powered movie, series, cast, collection, franchise, and metadata browsing -- IPTV M3U/Xtream playlist support with provider categories, favorites, hidden categories, EPG, and mobile/tablet fullscreen playback -- Optional ARVIO Cloud sync for profiles, settings, catalogs, IPTV state, watch state, and custom profile avatars -- Optional per-profile Trakt.tv integration for watchlist, history, progress, and continue watching -- Catalog management with manual URLs and public Trakt/MDBList list discovery -- Home-server source and catalog support for user-owned Jellyfin, Emby, and Plex libraries -- Third-party addon support for user-configured sources -- Watchlist and continue-watching state with profile isolation -- Subtitle and audio track selection, subtitle language filtering, and AI subtitle tools -- Profile PINs and custom profile avatars -- ExoPlayer/Media3 playback with TV remote, mobile, and tablet controls - +# ARVIO — Multi-Platform Media Hub & Browser + +[![Build Status](https://img.shields.io/github/actions/workflow/status/ProdigyV21/ARVIO/build-check.yml?branch=main&label=Build%20Check&style=flat-square)](https://github.com/ProdigyV21/ARVIO/actions) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](./LICENSE) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-purple?style=flat-square&logo=kotlin)](https://kotlinlang.org/) +[![Swift](https://img.shields.io/badge/Swift-5.10-orange?style=flat-square&logo=swift)](https://swift.org/) + +ARVIO is a production-grade media hub designed for Android (TV, Fire TV, phone, and tablet form factors) and iOS (SwiftUI shell). It provides unified media browsing, catalog configuration, IPTV playlist loading, and home-server integrations. + +> [!IMPORTANT] +> **Content & Source Policy:** ARVIO is a media browser and player interface for user-configured sources. It functions like a web browser: users must configure their own services, playlists, addons, or URLs. This repository does not host, store, sell, or distribute movies, series, live TV channels, playlists, or other copyrighted media. + +--- + +## 1. Project Overview + +ARVIO consists of: +- **Android App (`app`):** Written in Kotlin using Jetpack Compose, Compose for TV, and Media3/ExoPlayer. It supports TV remotes and touchscreen controls. +- **iOS App (`iosApp`):** Written in Swift and SwiftUI, serving as an additive native shell. +- **Backend Services (`supabase`):** Supabase database, authentication, and Edge Functions for proxying external APIs (TMDB, Trakt) and supporting TV code pairing. +- **Auth Page (`netlify-auth-site`):** A static HTML site deployed on Netlify that manages web-based user logins, pairing links, and cloud account management. + +--- + +## 2. Key Features + +- **Multi-Form Factor UI:** Native layouts optimized for Android TV/Fire TV D-pad navigation, as well as adaptive layouts for iOS/Android phones and tablets. +- **Metadata Indexing:** TMDB-powered movie, series, cast, collection, and franchise browsing. +- **IPTV Integration:** Support for M3U and Xtream Playlists with custom category ordering, hiding, favorites, and Electronic Program Guide (EPG) backfill. +- **ARVIO Cloud Sync:** Optional Cloud synchronization backed by Supabase for profiles, settings, catalogs, IPTV preferences, and profile avatars. +- **Trakt.tv Sync:** Profile-isolated Trakt integration for watched history, progress sync, and watchlists. +- **Home Server Support:** Source and catalog support for user-owned Jellyfin, Emby, and Plex libraries. +- **Addon Ecosystem:** Extensible source scraping using Stremio-compatible addons. +- **Player Enhancements:** Frame-rate matching, subtitle language filters, subtitle offset adjustments, audio track selection, and AI-powered subtitle tools. + +--- + +## 3. Architecture Summary + +ARVIO follows a clean architectural pattern: +- **UI Layer:** Jetpack Compose (Android) and SwiftUI (iOS) driving reactive state using ViewModels. +- **Repository Layer:** Encapsulates business logic, orchestrating database transactions, API requests, and data caching. +- **Data Source Layer:** Uses Retrofit/OkHttp for external APIs, Room/SQLite (Android) or local structures (iOS) for storage, and Datastore for settings. +- **Worker Layer:** Android WorkManager coordinates periodic background tasks (e.g. Trakt syncing). +- **Backend Layer:** Supabase Edge Functions act as secure proxies to hide client credentials for TMDB/Trakt and perform TV auth pairing. + +For details, refer to the [docs/architecture.md](./docs/architecture.md) guide. + +--- + +## 4. Folder Structure Overview + +```directory +ARVIO/ +├── app/ # Android Application Module (Kotlin) +│ ├── src/main/kotlin/ # Android source package com.arflix.tv +│ └── src/test/kotlin/ # Android local unit tests +├── benchmark/ # Android Macrobenchmark Module +├── iosApp/ # iOS Application Shell (Swift/SwiftUI) +│ ├── ARVIO/ # Xcode project source code +│ └── ci/ # Code signing & CI automation scripts +├── supabase/ # Supabase Edge Functions & DB Migrations +│ ├── functions/ # Deno/TypeScript serverless functions +│ └── migrations/ # Database schema migrations +├── netlify-auth-site/ # HTML/JS Landing & Account Deletion page +├── docs/ # Detailed developer and deploy guides +├── screenshots/ # Visual assets for README/Store listings +└── releases/ # Release notes history +``` -## Availability +--- -ARVIO is available on Google Play: +## 5. Installation/Setup -[Get it on Google Play](https://play.google.com/store/apps/details?id=com.arvio.tv) +Detailed local setup instructions can be found in the [docs/setup.md](./docs/setup.md) guide. -## Screenshots +### Quick Prerequisites +- **Android:** Android Studio Jellyfish+, JDK 17, Android SDK 35. +- **iOS:** macOS, Xcode 15+, XcodeGen `2.40.0+`. +- **Backend:** Node.js 18+, Supabase CLI. -| Home | Details | -|------|---------| -| ![Home screen](screenshots/home_v190.png) | ![Details screen](screenshots/details_v190.png) | +### Quick Start +1. Clone the repository: + ```bash + git clone https://github.com/ProdigyV21/ARVIO.git + cd ARVIO + ``` +2. Copy default secrets: + ```bash + cp secrets.defaults.properties secrets.properties + ``` +3. Open the project in **Android Studio** or generate the Xcode project: + ```bash + xcodegen generate --spec iosApp/project.yml --project iosApp + ``` -| Live TV | Collections | -|---------|-------------| -| ![Live TV screen](screenshots/live_tv_v1991.png) | ![Collections screen](screenshots/collections_v1991.png) | +--- -| Mobile | Profiles | -|--------|----------| -| ![Mobile screen](screenshots/mobile_home.webp) | ![Profiles screen](screenshots/profiles_v1991.png) | +## 6. Local Development Workflow -## Content And Source Policy +To build and run debug variants locally: -ARVIO is a media browser and player interface for user-configured sources. It works like a media player or browser: users provide their own services, playlists, addons, and URLs. +### Android (CLI) +- **Compile Play Store Debug APK:** + ```bash + ./gradlew :app:assemblePlayDebug + ``` +- **Install Play Store Debug to Connected Device/Emulator:** + ```bash + ./gradlew :app:installPlayDebug + ``` -This repository does not include hosted media content, bundled playlists, IPTV credentials, debrid accounts, third-party streaming catalogs, or links intended to enable unauthorized access to content. No movies, series, live TV channels, playlists, or other third-party media are hosted by this repository or by ARVIO. +### iOS (CLI) +- **Generate Xcode project:** + ```bash + xcodegen generate --spec iosApp/project.yml --project iosApp + ``` +- **Open Xcode:** + ```bash + open iosApp/ARVIO.xcodeproj + ``` -Users are solely responsible for their usage and must comply with applicable local laws. If you believe content accessed through an external source violates copyright law, contact the actual file host, service provider, or source maintainer. The ARVIO repository and developers cannot remove content hosted by third parties. +For more info, see [docs/development.md](./docs/development.md). -Contributors should not submit copyrighted media, credentials, private keys, access tokens, or links intended to enable unauthorized access to content. +--- -## Cloud Sync +## 7. Environment Variables -ARVIO Cloud is optional. When enabled, it can sync profiles, settings, catalogs, IPTV state, watch progress, watchlist state, and profile avatars across devices. See [PRIVACY.md](PRIVACY.md) for details and account deletion instructions. +Key parameters are read by Gradle from `secrets.properties` or environment variables in CI/CD. The default keys include: -## Build And Run +| Secret Key | Purpose | Default Value | +|------------|---------|---------------| +| `SUPABASE_URL` | Cloud Sync & Auth API Endpoint | `https://your-project.supabase.co` | +| `SUPABASE_ANON_KEY` | Supabase Public JWT Key | `your-supabase-anon-key` | +| `GOOGLE_WEB_CLIENT_ID` | OAuth Client ID for Google Auth | `your-google-web-client-id...` | +| `SENTRY_DSN` | Sentry crash tracking URL (optional) | `disabled` | +| `TMDB_API_KEY` | TMDB API Key (direct fallback) | `your-tmdb-api-key` | +| `TRAKT_CLIENT_ID` | Trakt Client ID (direct fallback) | `your-trakt-client-id` | +| `TRAKT_CLIENT_SECRET` | Trakt Client Secret (direct fallback) | `your-trakt-client-secret` | -Requirements: +--- -- Android Studio or Android SDK command-line tools -- JDK 17 -- Android SDK 35 +## 8. Usage Examples -Use the tracked Gradle wrapper: +### Configuring a Custom IPTV Playlist +1. Navigate to **Settings > IPTV Configurations**. +2. Select **Add M3U Playlist**. +3. Input your M3U URL and (optional) XMLTV EPG URL. +4. Save to start loading the channel list. -```bash -./gradlew :app:assemblePlayDebug -./gradlew :app:assembleSideloadDebug -``` +### Syncing Profiles +1. Go to the profile selection screen. +2. Select **Cloud Connect**. +3. If on TV, scan the QR code to pair your device. If on mobile, log in with your email/password. -On Windows PowerShell or Command Prompt: +--- -```powershell -.\gradlew.bat :app:assemblePlayDebug -.\gradlew.bat :app:assembleSideloadDebug -``` +## 9. Configuration Explanation -Install a debug build on a connected Android TV, Fire TV, emulator, phone, or tablet: +- **`secrets.properties`:** Stores local API credentials and dev keys. (Ignored by Git). +- **`keystore.properties`:** Holds details of the signing certificate (`storeFile`, `storePassword`, etc.) used to sign releases. (Ignored by Git). +- **`supabase/config.toml`:** Controls local Supabase emulation and proxy settings. +- **`netlify-auth-site/netlify.toml`:** Specifies redirection rules and headers for the Netlify static site. -```bash -./gradlew :app:installPlayDebug -./gradlew :app:installSideloadDebug -``` +See [docs/configuration.md](./docs/configuration.md) for details. -For network ADB devices: +--- -```bash -adb connect :5555 -adb install -r app/build/outputs/apk/sideload/debug/app-sideload-debug.apk -``` +## 10. Scripts/Commands Reference -Build variants: +| Command | Action | +|---------|--------| +| `./gradlew :app:assemblePlayDebug` | Builds Android Play Store Debug APK | +| `./gradlew :app:assembleSideloadDebug` | Builds Android Sideload Debug APK | +| `./gradlew :app:installPlayDebug` | Installs Play Store Debug APK on connected device | +| `./gradlew :app:testPlayDebugUnitTest` | Runs all local JUnit tests for Play Debug | +| `./gradlew detekt` | Runs Detekt static analysis checks | +| `xcodegen generate --spec iosApp/project.yml --project iosApp` | Regenerates the Xcode project files | -- `play`: Play Store build, self-update disabled. -- `sideload`: Direct APK build, self-update enabled. -- `debug`: development build. -- `staging`: release-like build signed with the debug keystore for upgrade testing. -- `release`: production build. Use a private release keystore for distribution. +--- -## Local Configuration +## 11. Contribution Guide References -Cloud sync, Google sign-in, and Supabase-backed auth require local secrets. Copy the defaults file and fill in real values: +We welcome developer contributions! Before submitting code: +1. Review the [CONTRIBUTING.md](./CONTRIBUTING.md) guide. +2. Comply with the [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md). +3. Ensure all code tests pass and comply with Detekt styling format. -```bash -cp secrets.defaults.properties secrets.properties -``` - -`secrets.properties` is ignored and must not be committed. +--- -TMDB and Trakt credentials are not committed to the repository. When a valid -Supabase config is present, app requests are routed through the tracked -`tmdb-proxy` and `trakt-proxy` Edge Functions, where those credentials should be -stored as Supabase function secrets. Forks that do not use those proxy functions -can still add their own local `TMDB_API_KEY`, `TRAKT_CLIENT_ID`, and -`TRAKT_CLIENT_SECRET` values in `secrets.properties` for direct local testing. +## 12. Documentation Navigation Section -For signed release builds, copy the keystore template and fill in local signing values: +To read more about specific parts of the codebase: -```bash -cp keystore.properties.template keystore.properties -``` +- [README.md](./README.md) - Main repository overview (this document). +- [CONTRIBUTING.md](./CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./docs/architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./docs/setup.md) - Environment installation checklist. +- [docs/development.md](./docs/development.md) - Development commands and workflows. +- [docs/configuration.md](./docs/configuration.md) - App parameters and credentials reference. +- [docs/api.md](./docs/api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./docs/deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./docs/troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./docs/ios-testflight.md) - iOS App Store/TestFlight packaging instructions. -`keystore.properties` and keystore files are ignored and must stay private. +--- -## Release Checks +## 13. Troubleshooting Section -Before publishing a build, run: +Common development issues: +- **Unable to locate a Java Runtime:** Ensure JDK 17 is installed. Run `export JAVA_HOME=/path/to/jdk` in your terminal shell. +- **Supabase Invalid JWT Signature:** Check that your local `secrets.properties` contains matching `SUPABASE_ANON_KEY` credentials matching your Supabase project. -```bash -./gradlew :app:compilePlayDebugKotlin -./gradlew :app:assemblePlayRelease -./gradlew :app:assembleSideloadRelease -``` +For exhaustive answers, refer to [docs/troubleshooting.md](./docs/troubleshooting.md). -Smoke-test startup, profile switching, playback, stream fallback, subtitle/audio switching, IPTV/EPG loading, addon add/remove, search, settings navigation, background sync, and repeated player open/close on the supported device classes. +--- -## Privacy +## 14. FAQ Section -See [PRIVACY.md](PRIVACY.md) for the privacy policy. Cloud account and synced data deletion is available at [auth.arvio.tv/delete-account](https://auth.arvio.tv/delete-account). +#### Q: Does ARVIO host or stream content directly? +No. ARVIO is a player shell and metadata browser. Users must supply their own content sources, playlists, or IPTV providers. -## License +#### Q: How do I test cloud synchronization locally? +Deploy local Supabase edge functions or use Supabase emulation via the CLI. Configure your `secrets.properties` to target the local emulator port. -This project is licensed under the Apache License 2.0. See [LICENSE](LICENSE) for details. +--- -## AI Disclosure +## 15. License and Acknowledgements -This application was developed with significant AI assistance. Contributions should still be reviewed, tested, and treated as normal source code changes. +This project is licensed under the **Apache License 2.0**. See the [LICENSE](./LICENSE) file for the full text. -If you have concerns about using AI-generated software, please do not use this application. +Developed with AI assistance. Contribution reviews and commits undergo normal engineering code standards. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2df2653a..c137b274 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -268,6 +268,12 @@ dependencies { // DataStore for preferences implementation("androidx.datastore:datastore-preferences:1.0.0") + // Room Database Caching + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") + // Google Cast SDK — mobile-only at runtime (guarded by DeviceType check), harmless on TV implementation("com.google.android.gms:play-services-cast-framework:21.4.0") implementation("androidx.mediarouter:mediarouter:1.7.0") diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt index 2d598ba1..a72580f3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt +++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt @@ -274,6 +274,26 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact ) } + fun scheduleCacheRefresh() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresCharging(true) + .build() + + val syncRequest = PeriodicWorkRequestBuilder( + com.arflix.tv.worker.CacheRefreshWorker.REFRESH_INTERVAL_HOURS, TimeUnit.HOURS + ) + .setConstraints(constraints) + .addTag(com.arflix.tv.worker.CacheRefreshWorker.TAG) + .build() + + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + com.arflix.tv.worker.CacheRefreshWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + syncRequest + ) + } + companion object { lateinit var instance: ArflixApplication private set diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 54ef1eea..db02316d 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -369,6 +369,7 @@ class MainActivity : ComponentActivity() { authRepository.get().checkAuthState() } ArflixApplication.instance.scheduleTraktSyncIfNeeded() + ArflixApplication.instance.scheduleCacheRefresh() lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { val repo = iptvRepository.get() runCatching { repo.warmupFromCacheOnly() } diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/AppDatabase.kt b/app/src/main/kotlin/com/arflix/tv/data/local/AppDatabase.kt new file mode 100644 index 00000000..77e1eb01 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/AppDatabase.kt @@ -0,0 +1,45 @@ +package com.arflix.tv.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [ + CachedMediaItem::class, + CachedCastMember::class, + CachedEpisode::class, + CachedSimilarItem::class, + CachedReview::class, + CachedCollectionRef::class, + CachedSearchQuery::class + ], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + + abstract fun cacheDao(): CacheDao + + companion object { + private const val DATABASE_NAME = "arvio_cache.db" + + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + DATABASE_NAME + ) + .fallbackToDestructiveMigration() + .build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/CacheDao.kt b/app/src/main/kotlin/com/arflix/tv/data/local/CacheDao.kt new file mode 100644 index 00000000..dbf8e226 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/CacheDao.kt @@ -0,0 +1,174 @@ +package com.arflix.tv.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction + +@Dao +interface CacheDao { + + // === Media Items === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMediaItem(item: CachedMediaItem) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMediaItems(items: List) + + @Query("SELECT * FROM media_items WHERE id = :id AND mediaType = :mediaType LIMIT 1") + suspend fun getMediaItem(id: Int, mediaType: String): CachedMediaItem? + + @Query("UPDATE media_items SET lastAccessed = :timestamp WHERE id = :id AND mediaType = :mediaType") + suspend fun updateMediaItemLastAccessed(id: Int, mediaType: String, timestamp: Long = System.currentTimeMillis()) + + @Query("DELETE FROM media_items WHERE id = :id AND mediaType = :mediaType") + suspend fun deleteMediaItem(id: Int, mediaType: String) + + // === Cast Members === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCastMembers(cast: List) + + @Query("SELECT * FROM cast_members WHERE mediaId = :mediaId AND mediaType = :mediaType") + suspend fun getCastMembers(mediaId: Int, mediaType: String): List + + @Query("DELETE FROM cast_members WHERE mediaId = :mediaId AND mediaType = :mediaType") + suspend fun deleteCastMembersForMedia(mediaId: Int, mediaType: String) + + // === Episodes === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertEpisodes(episodes: List) + + @Query("SELECT * FROM episodes WHERE tvId = :tvId AND seasonNumber = :seasonNumber ORDER BY episodeNumber ASC") + suspend fun getEpisodes(tvId: Int, seasonNumber: Int): List + + @Query("DELETE FROM episodes WHERE tvId = :tvId AND seasonNumber = :seasonNumber") + suspend fun deleteEpisodesForSeason(tvId: Int, seasonNumber: Int) + + // === Similar Items === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSimilarRelations(relations: List) + + @Query("DELETE FROM similar_items WHERE mediaId = :mediaId AND mediaType = :mediaType") + suspend fun deleteSimilarRelations(mediaId: Int, mediaType: String) + + @Transaction + @Query("SELECT mi.* FROM media_items mi INNER JOIN similar_items si ON mi.id = si.similarId AND mi.mediaType = si.similarType WHERE si.mediaId = :mediaId AND si.mediaType = :mediaType ORDER BY si.position ASC") + suspend fun getSimilarItems(mediaId: Int, mediaType: String): List + + // === Reviews === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertReviews(reviews: List) + + @Query("SELECT * FROM reviews WHERE mediaId = :mediaId AND mediaType = :mediaType ORDER BY rating DESC") + suspend fun getReviews(mediaId: Int, mediaType: String): List + + @Query("DELETE FROM reviews WHERE mediaId = :mediaId AND mediaType = :mediaType") + suspend fun deleteReviewsForMedia(mediaId: Int, mediaType: String) + + // === Collection / Catalog Refs === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCollectionRefs(refs: List) + + @Query("DELETE FROM collection_refs WHERE catalogId = :catalogId") + suspend fun deleteCollectionRefs(catalogId: String) + + @Transaction + @Query("SELECT mi.* FROM media_items mi INNER JOIN collection_refs cr ON mi.id = cr.mediaId AND mi.mediaType = cr.mediaType WHERE cr.catalogId = :catalogId ORDER BY cr.position ASC") + suspend fun getCollectionItems(catalogId: String): List + + // === Search History === + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSearchQuery(query: CachedSearchQuery) + + @Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit") + suspend fun getSearchHistory(limit: Int = 10): List + + @Query("DELETE FROM search_history WHERE `query` = :query") + suspend fun deleteSearchQuery(query: String) + + @Query("DELETE FROM search_history") + suspend fun clearSearchHistory() + + // === General Cache Cleanup === + + @Transaction + suspend fun saveSimilarItems(mediaId: Int, mediaType: String, items: List, relations: List) { + deleteSimilarRelations(mediaId, mediaType) + insertMediaItems(items) + insertSimilarRelations(relations) + } + + @Transaction + suspend fun saveCollectionItems(catalogId: String, items: List, refs: List) { + deleteCollectionRefs(catalogId) + insertMediaItems(items) + insertCollectionRefs(refs) + } + + @Transaction + suspend fun saveTvSeason(tvId: Int, seasonNumber: Int, episodes: List) { + deleteEpisodesForSeason(tvId, seasonNumber) + insertEpisodes(episodes) + } + + @Transaction + suspend fun saveCredits(mediaId: Int, mediaType: String, cast: List) { + deleteCastMembersForMedia(mediaId, mediaType) + insertCastMembers(cast) + } + + @Transaction + suspend fun saveReviews(mediaId: Int, mediaType: String, reviews: List) { + deleteReviewsForMedia(mediaId, mediaType) + insertReviews(reviews) + } + + @Query("DELETE FROM media_items") + suspend fun clearAllMediaItems() + + @Query("DELETE FROM cast_members") + suspend fun clearAllCastMembers() + + @Query("DELETE FROM episodes") + suspend fun clearAllEpisodes() + + @Query("DELETE FROM similar_items") + suspend fun clearAllSimilarItems() + + @Query("DELETE FROM reviews") + suspend fun clearAllReviews() + + @Query("DELETE FROM collection_refs") + suspend fun clearAllCollectionRefs() + + @Transaction + suspend fun clearAllMetadata() { + clearAllMediaItems() + clearAllCastMembers() + clearAllEpisodes() + clearAllSimilarItems() + clearAllReviews() + clearAllCollectionRefs() + clearSearchHistory() + } + + @Query("DELETE FROM media_items WHERE updatedAt < :staleTime") + suspend fun deleteStaleMediaItems(staleTime: Long) + + @Query("DELETE FROM cast_members WHERE createdAt < :staleTime") + suspend fun deleteStaleCastMembers(staleTime: Long) + + @Query("DELETE FROM episodes WHERE createdAt < :staleTime") + suspend fun deleteStaleEpisodes(staleTime: Long) + + @Query("SELECT * FROM media_items WHERE updatedAt < :staleTime ORDER BY lastAccessed DESC LIMIT :limit") + suspend fun getStaleMediaItems(staleTime: Long, limit: Int): List +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/CacheEntities.kt b/app/src/main/kotlin/com/arflix/tv/data/local/CacheEntities.kt new file mode 100644 index 00000000..a8764e2d --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/CacheEntities.kt @@ -0,0 +1,300 @@ +package com.arflix.tv.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.arflix.tv.data.model.CastMember +import com.arflix.tv.data.model.Episode +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.NextEpisode +import com.arflix.tv.data.model.Review +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +@Entity(tableName = "media_items", primaryKeys = ["id", "mediaType"]) +data class CachedMediaItem( + val id: Int, + val mediaType: String, // MOVIE or TV + val title: String, + val subtitle: String = "", + val overview: String = "", + val year: String = "", + val releaseDate: String? = null, + val rating: String = "", + val duration: String = "", + val imdbRating: String = "", + val tmdbRating: String = "", + val image: String = "", + val backdrop: String? = null, + val progress: Int = 0, + val isWatched: Boolean = false, + val traktId: Int? = null, + val badge: String? = null, + val genreIdsJson: String = "[]", // Serialized List + val originalLanguage: String? = null, + val primaryNetworkLogo: String? = null, + val isOngoing: Boolean = false, + val totalEpisodes: Int? = null, + val watchedEpisodes: Int? = null, + val nextEpisodeJson: String? = null, // Serialized NextEpisode? + val budget: Long? = null, + val revenue: Long? = null, + val status: String? = null, + val character: String = "", + val popularity: Float = 0f, + val addedAt: Long = 0L, + val sourceOrder: Int = Int.MAX_VALUE, + val timeRemainingLabel: String? = null, + val showPlaybackProgress: Boolean = true, + + // Tracking fields + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val lastAccessed: Long = System.currentTimeMillis() +) { + fun toDomain(gson: Gson): MediaItem { + val type = MediaType.valueOf(mediaType) + val genreIdsType = object : TypeToken>() {}.type + val genreIds: List = gson.fromJson(genreIdsJson, genreIdsType) ?: emptyList() + val nextEpisode: NextEpisode? = nextEpisodeJson?.let { + gson.fromJson(it, NextEpisode::class.java) + } + + return MediaItem( + id = id, + title = title, + subtitle = subtitle, + overview = overview, + year = year, + releaseDate = releaseDate, + rating = rating, + duration = duration, + imdbRating = imdbRating, + tmdbRating = tmdbRating, + mediaType = type, + image = image, + backdrop = backdrop, + progress = progress, + isWatched = isWatched, + traktId = traktId, + badge = badge, + genreIds = genreIds, + originalLanguage = originalLanguage, + primaryNetworkLogo = primaryNetworkLogo, + isOngoing = isOngoing, + totalEpisodes = totalEpisodes, + watchedEpisodes = watchedEpisodes, + nextEpisode = nextEpisode, + budget = budget, + revenue = revenue, + status = status, + character = character, + popularity = popularity, + addedAt = addedAt, + sourceOrder = sourceOrder, + timeRemainingLabel = timeRemainingLabel, + showPlaybackProgress = showPlaybackProgress + ) + } + + companion object { + fun fromDomain(item: MediaItem, gson: Gson): CachedMediaItem { + return CachedMediaItem( + id = item.id, + mediaType = item.mediaType.name, + title = item.title, + subtitle = item.subtitle, + overview = item.overview, + year = item.year, + releaseDate = item.releaseDate, + rating = item.rating, + duration = item.duration, + imdbRating = item.imdbRating, + tmdbRating = item.tmdbRating, + image = item.image, + backdrop = item.backdrop, + progress = item.progress, + isWatched = item.isWatched, + traktId = item.traktId, + badge = item.badge, + genreIdsJson = gson.toJson(item.genreIds), + originalLanguage = item.originalLanguage, + primaryNetworkLogo = item.primaryNetworkLogo, + isOngoing = item.isOngoing, + totalEpisodes = item.totalEpisodes, + watchedEpisodes = item.watchedEpisodes, + nextEpisodeJson = item.nextEpisode?.let { gson.toJson(it) }, + budget = item.budget, + revenue = item.revenue, + status = item.status, + character = item.character, + popularity = item.popularity, + addedAt = item.addedAt, + sourceOrder = item.sourceOrder, + timeRemainingLabel = item.timeRemainingLabel, + showPlaybackProgress = item.showPlaybackProgress + ) + } + } +} + +@Entity(tableName = "cast_members", primaryKeys = ["id", "mediaId", "mediaType", "character"]) +data class CachedCastMember( + val id: Int, + val mediaId: Int, + val mediaType: String, // MOVIE or TV + val name: String, + val character: String, + val profilePath: String?, + + // Tracking fields + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) { + fun toDomain(): CastMember { + return CastMember( + id = id, + name = name, + character = character, + profilePath = profilePath + ) + } + + companion object { + fun fromDomain(cast: CastMember, mediaId: Int, mediaType: MediaType): CachedCastMember { + return CachedCastMember( + id = cast.id, + mediaId = mediaId, + mediaType = mediaType.name, + name = cast.name, + character = cast.character, + profilePath = cast.profilePath + ) + } + } +} + +@Entity(tableName = "episodes", primaryKeys = ["id", "tvId", "seasonNumber", "episodeNumber"]) +data class CachedEpisode( + val id: Int, + val tvId: Int, + val seasonNumber: Int, + val episodeNumber: Int, + val name: String, + val overview: String = "", + val stillPath: String? = null, + val voteAverage: Float = 0f, + val runtime: Int = 0, + val airDate: String = "", + val isWatched: Boolean = false, + + // Tracking fields + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) { + fun toDomain(): Episode { + return Episode( + id = id, + episodeNumber = episodeNumber, + seasonNumber = seasonNumber, + name = name, + overview = overview, + stillPath = stillPath, + voteAverage = voteAverage, + runtime = runtime, + airDate = airDate, + isWatched = isWatched + ) + } + + companion object { + fun fromDomain(ep: Episode, tvId: Int): CachedEpisode { + return CachedEpisode( + id = ep.id, + tvId = tvId, + seasonNumber = ep.seasonNumber, + episodeNumber = ep.episodeNumber, + name = ep.name, + overview = ep.overview, + stillPath = ep.stillPath, + voteAverage = ep.voteAverage, + runtime = ep.runtime, + airDate = ep.airDate, + isWatched = ep.isWatched + ) + } + } +} + +@Entity(tableName = "similar_items", primaryKeys = ["mediaId", "mediaType", "similarId", "similarType"]) +data class CachedSimilarItem( + val mediaId: Int, + val mediaType: String, + val similarId: Int, + val similarType: String, + val position: Int = 0, // Preserve similar order + + // Tracking fields + val createdAt: Long = System.currentTimeMillis() +) + +@Entity(tableName = "reviews") +data class CachedReview( + @PrimaryKey val id: String, + val mediaId: Int, + val mediaType: String, + val author: String, + val authorUsername: String = "", + val authorAvatar: String? = null, + val content: String, + val rating: Float? = null, + val createdAtStr: String = "", + + // Tracking fields + val createdAt: Long = System.currentTimeMillis() +) { + fun toDomain(): Review { + return Review( + id = id, + author = author, + authorUsername = authorUsername, + authorAvatar = authorAvatar, + content = content, + rating = rating, + createdAt = createdAtStr + ) + } + + companion object { + fun fromDomain(review: Review, mediaId: Int, mediaType: MediaType): CachedReview { + return CachedReview( + id = review.id, + mediaId = mediaId, + mediaType = mediaType.name, + author = review.author, + authorUsername = review.authorUsername, + authorAvatar = review.authorAvatar, + content = review.content, + rating = review.rating, + createdAtStr = review.createdAt + ) + } + } +} + +@Entity(tableName = "collection_refs", primaryKeys = ["catalogId", "mediaId", "mediaType"]) +data class CachedCollectionRef( + val catalogId: String, + val mediaId: Int, + val mediaType: String, + val position: Int = 0, + + // Tracking fields + val createdAt: Long = System.currentTimeMillis() +) + +@Entity(tableName = "search_history") +data class CachedSearchQuery( + @PrimaryKey val query: String, + val timestamp: Long = System.currentTimeMillis() +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/CachePolicyManager.kt b/app/src/main/kotlin/com/arflix/tv/data/local/CachePolicyManager.kt new file mode 100644 index 00000000..4bf1d1ac --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/CachePolicyManager.kt @@ -0,0 +1,61 @@ +package com.arflix.tv.data.local + +import com.arflix.tv.data.model.MediaType +import java.util.concurrent.TimeUnit + +object CachePolicyManager { + + // Centralized TTL Configurations + val MOVIE_TTL_MS = TimeUnit.DAYS.toMillis(7) // 7 Days + val TV_TTL_MS = TimeUnit.DAYS.toMillis(3) // 3 Days + val CAST_TTL_MS = TimeUnit.DAYS.toMillis(14) // 14 Days + val EPISODES_TTL_MS = TimeUnit.DAYS.toMillis(3) // 3 Days (Same as TV details) + val REVIEWS_TTL_MS = TimeUnit.DAYS.toMillis(7) // 7 Days + val COLLECTION_TTL_MS = TimeUnit.DAYS.toMillis(1) // 1 Day + + /** + * Check if a cached media item is fresh based on its type-specific TTL + */ + fun isMediaItemFresh(cachedItem: CachedMediaItem, forceRefresh: Boolean = false): Boolean { + if (forceRefresh) return false + val ttl = if (cachedItem.mediaType == MediaType.MOVIE.name) MOVIE_TTL_MS else TV_TTL_MS + val age = System.currentTimeMillis() - cachedItem.updatedAt + return age < ttl + } + + /** + * Check if cached cast/crew data is fresh + */ + fun isCastFresh(updatedAt: Long, forceRefresh: Boolean = false): Boolean { + if (forceRefresh) return false + val age = System.currentTimeMillis() - updatedAt + return age < CAST_TTL_MS + } + + /** + * Check if cached episodes data is fresh + */ + fun isEpisodesFresh(updatedAt: Long, forceRefresh: Boolean = false): Boolean { + if (forceRefresh) return false + val age = System.currentTimeMillis() - updatedAt + return age < EPISODES_TTL_MS + } + + /** + * Check if cached reviews are fresh + */ + fun isReviewsFresh(updatedAt: Long, forceRefresh: Boolean = false): Boolean { + if (forceRefresh) return false + val age = System.currentTimeMillis() - updatedAt + return age < REVIEWS_TTL_MS + } + + /** + * Check if cached collection/catalog items are fresh + */ + fun isCollectionFresh(updatedAt: Long, forceRefresh: Boolean = false): Boolean { + if (forceRefresh) return false + val age = System.currentTimeMillis() - updatedAt + return age < COLLECTION_TTL_MS + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 4343bc61..182a16e3 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -52,6 +52,18 @@ import java.util.Calendar import java.util.Locale import java.util.concurrent.ConcurrentHashMap import com.arflix.tv.util.ParsedCatalogUrl +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.io.File +import coil.imageLoader +import com.arflix.tv.data.local.CachePolicyManager +import com.arflix.tv.data.local.CachedMediaItem +import com.arflix.tv.data.local.CachedCastMember +import com.arflix.tv.data.local.CachedEpisode +import com.arflix.tv.data.local.CachedSimilarItem +import com.arflix.tv.data.local.CachedReview +import com.arflix.tv.data.local.CachedCollectionRef import javax.inject.Inject import javax.inject.Singleton @@ -84,7 +96,9 @@ class MediaRepository @Inject constructor( private val traktApi: TraktApi, private val okHttpClient: OkHttpClient, private val streamRepository: StreamRepository, - private val homeServerRepository: HomeServerRepository + private val homeServerRepository: HomeServerRepository, + private val cacheDao: com.arflix.tv.data.local.CacheDao, + @ApplicationContext private val context: android.content.Context ) { data class CategoryPageResult( @@ -222,16 +236,21 @@ class MediaRepository @Inject constructor( return cached } + // 2. Check Database Cache + val dbItems = cacheDao.getCollectionItems(catalog.id) + if (dbItems.isNotEmpty()) { + val isFresh = CachePolicyManager.isCollectionFresh(dbItems.first().updatedAt) + if (isFresh && dbItems.size >= requiredCount.coerceAtLeast(1)) { + val refs = dbItems.map { MediaType.valueOf(it.mediaType) to it.id } + collectionRefsCache[cacheKey] = CacheEntry(refs, System.currentTimeMillis()) + return refs + } + } + val targetCount = requiredCount.coerceAtLeast(1) - // SERVICE and GENRE rails page through TMDB/addon catalogs on demand, - // so let the per-source budget grow with the user's scroll position - // instead of clamping at the default 72/96/120 ceiling. FRANCHISE and - // other fixed groups keep the small cap. val unlimitedGroup = catalog.collectionGroup == CollectionGroupKind.SERVICE || catalog.collectionGroup == CollectionGroupKind.GENRE - // Resolve all sources in parallel so a slow/failed source never blocks the - // others — this alone fixes "empty" genre collections where one source 404s. val sourceBudgets = catalog.collectionSources.map { source -> if (unlimitedGroup) { (targetCount + 20).coerceAtLeast(40) @@ -241,48 +260,58 @@ class MediaRepository @Inject constructor( else -> (targetCount + 8).coerceAtLeast(24).coerceAtMost(72) } } - val perSourceRefs: List>> = coroutineScope { - catalog.collectionSources.mapIndexed { index, source -> - async { - runCatching { - resolveCollectionSourceRefs( - source, - offset = 0, - limit = sourceBudgets[index] - ) - }.getOrDefault(emptyList()) + + val resolved = try { + val perSourceRefs: List>> = coroutineScope { + catalog.collectionSources.mapIndexed { index, source -> + async { + runCatching { + resolveCollectionSourceRefs( + source, + offset = 0, + limit = sourceBudgets[index] + ) + }.getOrDefault(emptyList()) + } + }.map { it.await() } + } + + val refs = LinkedHashSet>() + cached?.forEach { refs.add(it) } + + if (catalog.collectionGroup == CollectionGroupKind.GENRE) { + val movieQueue = ArrayDeque>() + val tvQueue = ArrayDeque>() + perSourceRefs.flatten().forEach { ref -> + if (ref.first == MediaType.MOVIE) movieQueue.addLast(ref) else tvQueue.addLast(ref) } - }.map { it.await() } - } + while ((movieQueue.isNotEmpty() || tvQueue.isNotEmpty()) && refs.size < targetCount) { + if (movieQueue.isNotEmpty()) refs.add(movieQueue.removeFirst()) + if (tvQueue.isNotEmpty() && refs.size < targetCount) refs.add(tvQueue.removeFirst()) + } + movieQueue.forEach { refs.add(it) } + tvQueue.forEach { refs.add(it) } + } else { + perSourceRefs.forEach { sourceRefs -> + sourceRefs.forEach { refs.add(it) } + } + } - val refs = LinkedHashSet>() - cached?.forEach { refs.add(it) } - - // For GENRE collections, interleave movie and series refs so the - // first page always shows a mix rather than "all movies, then TV". - if (catalog.collectionGroup == CollectionGroupKind.GENRE) { - val movieQueue = ArrayDeque>() - val tvQueue = ArrayDeque>() - perSourceRefs.flatten().forEach { ref -> - if (ref.first == MediaType.MOVIE) movieQueue.addLast(ref) else tvQueue.addLast(ref) - } - while ((movieQueue.isNotEmpty() || tvQueue.isNotEmpty()) && refs.size < targetCount) { - if (movieQueue.isNotEmpty()) refs.add(movieQueue.removeFirst()) - if (tvQueue.isNotEmpty() && refs.size < targetCount) refs.add(tvQueue.removeFirst()) - } - // Drain any remaining so pagination beyond the first page still has items. - movieQueue.forEach { refs.add(it) } - tvQueue.forEach { refs.add(it) } - } else { - perSourceRefs.forEach { sourceRefs -> - sourceRefs.forEach { refs.add(it) } + val result = refs.toList() + if (result.isNotEmpty()) { + collectionRefsCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) + } + result + } catch (e: Exception) { + if (dbItems.isNotEmpty()) { + val result = dbItems.map { MediaType.valueOf(it.mediaType) to it.id } + collectionRefsCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) + result + } else { + throw e } } - val resolved = refs.toList() - if (resolved.isNotEmpty()) { - collectionRefsCache[cacheKey] = CacheEntry(resolved, System.currentTimeMillis()) - } return resolved } @@ -1837,7 +1866,7 @@ class MediaRepository @Inject constructor( val itemsByRef = LinkedHashMap, MediaItem>() val missingRefs = mutableListOf>() pageRefs.forEach { (type, tmdbId) -> - val cachedItem = getCachedItem(type, tmdbId) + val cachedItem = getCachedItem(type, tmdbId) ?: cacheDao.getMediaItem(tmdbId, type.name)?.toDomain(gson) if (cachedItem != null) { itemsByRef[type to tmdbId] = cachedItem } else { @@ -1862,7 +1891,24 @@ class MediaRepository @Inject constructor( } jobs.forEach { it.await() } val items = pageRefs.mapNotNull { itemsByRef[it] } - if (items.isNotEmpty()) cacheItems(items) + if (items.isNotEmpty()) { + cacheItems(items) + val cachedItems = items.map { CachedMediaItem.fromDomain(it, gson).copy(updatedAt = System.currentTimeMillis()) } + val colRefs = items.mapIndexed { index, item -> + CachedCollectionRef( + catalogId = catalog.id, + mediaId = item.id, + mediaType = item.mediaType.name, + position = offset + index + ) + } + if (offset == 0) { + cacheDao.saveCollectionItems(catalog.id, cachedItems, colRefs) + } else { + cacheDao.insertMediaItems(cachedItems) + cacheDao.insertCollectionRefs(colRefs) + } + } CategoryPageResult(items = items, hasMore = offset + pageRefs.size < refs.size) } @@ -2628,7 +2674,7 @@ class MediaRepository @Inject constructor( /** * Get movie details (cached) */ - suspend fun getMovieDetails(movieId: Int): MediaItem { + suspend fun getMovieDetails(movieId: Int, forceRefresh: Boolean = false): MediaItem { val cacheKey = "movie_$movieId" getFromCache(detailsCache, cacheKey)?.let { cached -> if (cacheKey in fullDetailsCacheKeys) { @@ -2641,23 +2687,55 @@ class MediaRepository @Inject constructor( } } - val item = coroutineScope { - val detailsDeferred = async { tmdbApi.getMovieDetails(movieId, apiKey, language = contentLanguage) } - val externalIdsDeferred = async { resolveExternalIds(MediaType.MOVIE, movieId) } + // 2. Check Database Cache + val cachedEntity = cacheDao.getMediaItem(movieId, MediaType.MOVIE.name) + if (cachedEntity != null) { + cacheDao.updateMediaItemLastAccessed(movieId, MediaType.MOVIE.name) + val isFresh = CachePolicyManager.isMediaItemFresh(cachedEntity, forceRefresh) + if (isFresh) { + val domainItem = cachedEntity.toDomain(gson) + cacheFullDetailsItem(domainItem) + prefetchDetailsDependencies(MediaType.MOVIE, movieId, domainItem) + return domainItem + } + } - val details = detailsDeferred.await() - val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.MOVIE, movieId, it) } - val imdbRating = imdbId?.let { getImdbRating(MediaType.MOVIE, movieId, it) } - details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + // 3. Network Fetch + return try { + val item = coroutineScope { + val detailsDeferred = async { tmdbApi.getMovieDetails(movieId, apiKey, language = contentLanguage) } + val externalIdsDeferred = async { resolveExternalIds(MediaType.MOVIE, movieId) } + + val details = detailsDeferred.await() + val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.MOVIE, movieId, it) } + val imdbRating = imdbId?.let { getImdbRating(MediaType.MOVIE, movieId, it) } + details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + } + // Save to DB + val cachedItem = CachedMediaItem.fromDomain(item, gson).copy( + updatedAt = System.currentTimeMillis(), + lastAccessed = System.currentTimeMillis() + ) + cacheDao.insertMediaItem(cachedItem) + cacheFullDetailsItem(item) + prefetchDetailsDependencies(MediaType.MOVIE, movieId, item) + item + } catch (e: Exception) { + // Fallback to stale DB cache if network failed + if (cachedEntity != null) { + val domainItem = cachedEntity.toDomain(gson) + cacheFullDetailsItem(domainItem) + domainItem + } else { + throw e + } } - cacheFullDetailsItem(item) - return item } /** * Get TV show details (cached) */ - suspend fun getTvDetails(tvId: Int): MediaItem { + suspend fun getTvDetails(tvId: Int, forceRefresh: Boolean = false): MediaItem { val cacheKey = "tv_$tvId" getFromCache(detailsCache, cacheKey)?.let { cached -> if (cacheKey in fullDetailsCacheKeys) { @@ -2670,17 +2748,123 @@ class MediaRepository @Inject constructor( } } - val item = coroutineScope { - val detailsDeferred = async { tmdbApi.getTvDetails(tvId, apiKey, language = contentLanguage) } - val externalIdsDeferred = async { resolveExternalIds(MediaType.TV, tvId) } + // 2. Check Database Cache + val cachedEntity = cacheDao.getMediaItem(tvId, MediaType.TV.name) + if (cachedEntity != null) { + cacheDao.updateMediaItemLastAccessed(tvId, MediaType.TV.name) + val isFresh = CachePolicyManager.isMediaItemFresh(cachedEntity, forceRefresh) + if (isFresh) { + val domainItem = cachedEntity.toDomain(gson) + cacheFullDetailsItem(domainItem) + prefetchDetailsDependencies(MediaType.TV, tvId, domainItem) + return domainItem + } + } - val details = detailsDeferred.await() - val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.TV, tvId, it) } - val imdbRating = imdbId?.let { getImdbRating(MediaType.TV, tvId, it) } - details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + // 3. Network Fetch + return try { + val item = coroutineScope { + val detailsDeferred = async { tmdbApi.getTvDetails(tvId, apiKey, language = contentLanguage) } + val externalIdsDeferred = async { resolveExternalIds(MediaType.TV, tvId) } + + val details = detailsDeferred.await() + val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.TV, tvId, it) } + val imdbRating = imdbId?.let { getImdbRating(MediaType.TV, tvId, it) } + details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + } + // Save to DB + val cachedItem = CachedMediaItem.fromDomain(item, gson).copy( + updatedAt = System.currentTimeMillis(), + lastAccessed = System.currentTimeMillis() + ) + cacheDao.insertMediaItem(cachedItem) + cacheFullDetailsItem(item) + prefetchDetailsDependencies(MediaType.TV, tvId, item) + item + } catch (e: Exception) { + // Fallback to stale DB cache if network failed + if (cachedEntity != null) { + val domainItem = cachedEntity.toDomain(gson) + cacheFullDetailsItem(domainItem) + domainItem + } else { + throw e + } } - cacheFullDetailsItem(item) - return item + } + + private fun prefetchDetailsDependencies(mediaType: MediaType, mediaId: Int, item: MediaItem) { + CoroutineScope(Dispatchers.IO).launch { + runCatching { getCast(mediaType, mediaId) } + runCatching { getSimilar(mediaType, mediaId) } + runCatching { getReviews(mediaType, mediaId) } + runCatching { + if (item.image.isNotBlank()) { + val request = coil.request.ImageRequest.Builder(context) + .data(item.image) + .build() + context.imageLoader.enqueue(request) + } + item.backdrop?.let { backdrop -> + if (backdrop.isNotBlank()) { + val request = coil.request.ImageRequest.Builder(context) + .data(backdrop) + .build() + context.imageLoader.enqueue(request) + } + } + } + } + } + + // === STORAGE / CACHE CONTROL === + + fun getMetadataCacheSize(): Long { + var size = 0L + val dbFile = context.getDatabasePath("arvio_cache.db") + if (dbFile.exists()) size += dbFile.length() + val walFile = File(dbFile.absolutePath + "-wal") + if (walFile.exists()) size += walFile.length() + val shmFile = File(dbFile.absolutePath + "-shm") + if (shmFile.exists()) size += shmFile.length() + return size + } + + suspend fun clearMetadataCache() { + cacheDao.clearAllMetadata() + } + + fun getArtworkCacheSize(): Long { + val cacheDir = context.cacheDir.resolve("image_cache") + return getFolderSize(cacheDir) + } + + private fun getFolderSize(file: File): Long { + if (!file.exists()) return 0L + if (file.isFile) return file.length() + var size = 0L + val files = file.listFiles() ?: return 0L + for (f in files) { + size += getFolderSize(f) + } + return size + } + + fun clearArtworkCache() { + context.imageLoader.diskCache?.clear() + context.imageLoader.memoryCache?.clear() + } + + suspend fun getMetadataCacheSizeAsync(): Long = withContext(Dispatchers.IO) { + getMetadataCacheSize() + } + + suspend fun getArtworkCacheSizeAsync(): Long = withContext(Dispatchers.IO) { + getArtworkCacheSize() + } + + suspend fun clearArtworkCacheAsync() = withContext(Dispatchers.IO) { + clearArtworkCache() } /** @@ -2712,14 +2896,8 @@ class MediaRepository @Inject constructor( /** * Get season episodes with Trakt watched status */ - suspend fun getSeasonEpisodes(tvId: Int, seasonNumber: Int): List { - val cacheKey = "tv_${tvId}_season_$seasonNumber" - val cachedEpisodes = getFromCache(seasonEpisodesCache, cacheKey) - - // First ensure the global watched cache is initialized. + private suspend fun applyWatchedStatusToEpisodes(tvId: Int, seasonNumber: Int, episodes: List): List { traktRepository.initializeWatchedCache() - - // Get watched episodes - try global cache first (faster, more reliable). val watchedEpisodes = if (traktRepository.hasWatchedEpisodes(tvId)) { traktRepository.getWatchedEpisodesFromCache() } else { @@ -2730,6 +2908,13 @@ class MediaRepository @Inject constructor( } } val hasShowWatchedData = watchedEpisodes.any { it.startsWith("show_tmdb:$tvId:") } + return episodes.map { episode -> + val episodeKey = "show_tmdb:$tvId:${episode.seasonNumber}:${episode.episodeNumber}" + episode.copy( + isWatched = if (hasShowWatchedData) episodeKey in watchedEpisodes else episode.isWatched + ) + } + } // Re-apply watched status on cached episodes so stale season cache doesn't hide badges. if (cachedEpisodes != null) { @@ -2762,67 +2947,140 @@ class MediaRepository @Inject constructor( isWatched = episodeKey in watchedEpisodes ) } - seasonEpisodesCache[cacheKey] = CacheEntry(episodes, System.currentTimeMillis()) - return episodes } /** * Get cast members (cached) */ - suspend fun getCast(mediaType: MediaType, mediaId: Int): List { + suspend fun getCast(mediaType: MediaType, mediaId: Int, forceRefresh: Boolean = false): List { val cacheKey = "${mediaType}_cast_$mediaId" - getFromCache(castCache, cacheKey)?.let { return it } + + // 1. Check Memory Cache first + if (!forceRefresh) { + getFromCache(castCache, cacheKey)?.let { return it } + } + // 2. Check Database Cache + val dbCast = cacheDao.getCastMembers(mediaId, mediaType.name) + if (dbCast.isNotEmpty()) { + val isFresh = CachePolicyManager.isCastFresh(dbCast.first().updatedAt, forceRefresh) + if (isFresh) { + val domainCast = dbCast.map { it.toDomain() } + castCache[cacheKey] = CacheEntry(domainCast, System.currentTimeMillis()) + return domainCast + } + } + + // 3. Network Fetch val type = if (mediaType == MediaType.TV) "tv" else "movie" - val credits = tmdbApi.getCredits(type, mediaId, apiKey, language = contentLanguage) + return try { + val credits = tmdbApi.getCredits(type, mediaId, apiKey, language = contentLanguage) - // Find the director from crew and prepend as the first cast member - val director = credits.crew.firstOrNull { it.job == "Director" } + // Find the director from crew and prepend as the first cast member + val director = credits.crew.firstOrNull { it.job == "Director" } - val castMembers = credits.cast - .distinctBy { it.id } // TMDB can occasionally return duplicate cast IDs. - .take(15) - .map { it.toCastMember() } + val castMembers = credits.cast + .distinctBy { it.id } // TMDB can occasionally return duplicate cast IDs. + .take(15) + .map { it.toCastMember() } - val result = if (director != null) { - listOf(director.toDirectorCastMember()) + castMembers - } else { - castMembers + val result = if (director != null) { + listOf(director.toDirectorCastMember()) + castMembers + } else { + castMembers + } + + // Save to DB + val cachedCast = result.map { CachedCastMember.fromDomain(it, mediaId, mediaType) } + cacheDao.saveCredits(mediaId, mediaType.name, cachedCast) + + // Update memory cache + castCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) + result + } catch (e: Exception) { + // Fallback to stale DB cache + if (dbCast.isNotEmpty()) { + val domainCast = dbCast.map { it.toDomain() } + castCache[cacheKey] = CacheEntry(domainCast, System.currentTimeMillis()) + domainCast + } else { + throw e + } } - castCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) - return result } /** * Get recommended content (cached) * Falls back to similar if recommendations are empty */ - suspend fun getSimilar(mediaType: MediaType, mediaId: Int): List { + suspend fun getSimilar(mediaType: MediaType, mediaId: Int, forceRefresh: Boolean = false): List { val cacheKey = "${mediaType}_similar_$mediaId" - getFromCache(similarCache, cacheKey)?.let { return it } + + // 1. Check Memory Cache first + if (!forceRefresh) { + getFromCache(similarCache, cacheKey)?.let { return it } + } - val type = if (mediaType == MediaType.TV) "tv" else "movie" - val recommendations = try { - tmdbApi.getRecommendations(type, mediaId, apiKey, language = contentLanguage) - } catch (e: Exception) { - null + // 2. Check Database Cache + val dbSimilar = cacheDao.getSimilarItems(mediaId, mediaType.name) + if (dbSimilar.isNotEmpty()) { + val isFresh = CachePolicyManager.isMediaItemFresh(dbSimilar.first(), forceRefresh) + if (isFresh) { + val domainSimilar = dbSimilar.map { it.toDomain(gson) } + similarCache[cacheKey] = CacheEntry(domainSimilar, System.currentTimeMillis()) + return domainSimilar + } } - val result = if (recommendations != null && recommendations.results.isNotEmpty()) { - recommendations.results - .map { it.toMediaItem(mediaType) } - .distinctBy { it.id } - .take(12) - } else { - val similar = tmdbApi.getSimilar(type, mediaId, apiKey, language = contentLanguage) - similar.results - .map { it.toMediaItem(mediaType) } - .distinctBy { it.id } - .take(12) + // 3. Network Fetch + val type = if (mediaType == MediaType.TV) "tv" else "movie" + return try { + val recommendations = try { + tmdbApi.getRecommendations(type, mediaId, apiKey, language = contentLanguage) + } catch (e: Exception) { + null + } + + val result = if (recommendations != null && recommendations.results.isNotEmpty()) { + recommendations.results + .map { it.toMediaItem(mediaType) } + .distinctBy { it.id } + .take(12) + } else { + val similar = tmdbApi.getSimilar(type, mediaId, apiKey, language = contentLanguage) + similar.results + .map { it.toMediaItem(mediaType) } + .distinctBy { it.id } + .take(12) + } + + // Save to DB + val cachedItems = result.map { CachedMediaItem.fromDomain(it, gson).copy(updatedAt = System.currentTimeMillis()) } + val relations = result.mapIndexed { index, item -> + CachedSimilarItem( + mediaId = mediaId, + mediaType = mediaType.name, + similarId = item.id, + similarType = item.mediaType.name, + position = index + ) + } + cacheDao.saveSimilarItems(mediaId, mediaType.name, cachedItems, relations) + + // Update memory cache and return + similarCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) + cacheItems(result) + result + } catch (e: Exception) { + // Fallback to stale DB cache + if (dbSimilar.isNotEmpty()) { + val domainSimilar = dbSimilar.map { it.toDomain(gson) } + similarCache[cacheKey] = CacheEntry(domainSimilar, System.currentTimeMillis()) + domainSimilar + } else { + throw e + } } - similarCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) - cacheItems(result) - return result } /** @@ -3059,10 +3317,26 @@ class MediaRepository @Inject constructor( /** * Get reviews for a movie or TV show from TMDB (cached) */ - suspend fun getReviews(mediaType: MediaType, mediaId: Int): List { + suspend fun getReviews(mediaType: MediaType, mediaId: Int, forceRefresh: Boolean = false): List { val cacheKey = "${mediaType}_reviews_$mediaId" - getFromCache(reviewsCache, cacheKey)?.let { return it } + + // 1. Check Memory Cache first + if (!forceRefresh) { + getFromCache(reviewsCache, cacheKey)?.let { return it } + } + // 2. Check Database Cache + val dbReviews = cacheDao.getReviews(mediaId, mediaType.name) + if (dbReviews.isNotEmpty()) { + val isFresh = CachePolicyManager.isReviewsFresh(dbReviews.first().createdAt, forceRefresh) + if (isFresh) { + val domainReviews = dbReviews.map { it.toDomain() } + reviewsCache[cacheKey] = CacheEntry(domainReviews, System.currentTimeMillis()) + return domainReviews + } + } + + // 3. Network Fetch val type = if (mediaType == MediaType.TV) "tv" else "movie" return try { val response = tmdbApi.getReviews(type, mediaId, apiKey, language = contentLanguage) @@ -3083,10 +3357,23 @@ class MediaRepository @Inject constructor( createdAt = review.createdAt ) } + + // Save to DB + val cachedReviews = reviews.map { CachedReview.fromDomain(it, mediaId, mediaType) } + cacheDao.saveReviews(mediaId, mediaType.name, cachedReviews) + + // Update memory cache reviewsCache[cacheKey] = CacheEntry(reviews, System.currentTimeMillis()) reviews } catch (e: Exception) { - emptyList() + // Fallback to stale DB cache + if (dbReviews.isNotEmpty()) { + val domainReviews = dbReviews.map { it.toDomain() } + reviewsCache[cacheKey] = CacheEntry(domainReviews, System.currentTimeMillis()) + domainReviews + } else { + emptyList() + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 7dfc4363..5af7d04a 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -150,4 +150,16 @@ object AppModule { fun provideJikanApi(@Named("jikan") retrofit: Retrofit): com.arflix.tv.data.api.JikanApi { return retrofit.create(com.arflix.tv.data.api.JikanApi::class.java) } + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context): com.arflix.tv.data.local.AppDatabase { + return com.arflix.tv.data.local.AppDatabase.getDatabase(context) + } + + @Provides + @Singleton + fun provideCacheDao(appDatabase: com.arflix.tv.data.local.AppDatabase): com.arflix.tv.data.local.CacheDao { + return appDatabase.cacheDao() + } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 26d174b9..773cd705 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -374,6 +374,7 @@ fun SettingsScreen( add("home_server") add("appearance") add("network") + add("storage") } } val sectionMaxIndex: (String) -> Int = { section -> @@ -388,6 +389,7 @@ fun SettingsScreen( "catalogs" -> uiState.catalogs.size // Add + rows "stremio" -> stremioAddons.size // rows + add button "accounts" -> 4 // Cloud + Trakt + Force Sync + App Update + Privacy/Data + "storage" -> 2 // Clear metadata, Clear artwork, Clear all else -> 0 } } @@ -1138,6 +1140,7 @@ fun SettingsScreen( "catalogs" -> Icons.Default.Widgets "stremio" -> Icons.Default.Extension "accounts" -> Icons.Default.Person + "storage" -> Icons.Default.Delete else -> Icons.Default.Settings }, title = when (section) { @@ -1153,6 +1156,7 @@ fun SettingsScreen( "catalogs" -> stringResource(R.string.catalogs) "stremio" -> stringResource(R.string.addons) "accounts" -> stringResource(R.string.accounts) + "storage" -> "Storage" else -> section.replaceFirstChar { it.uppercase() } }, isSelected = sectionIndex == index, @@ -1495,6 +1499,15 @@ fun SettingsScreen( onInstallUpdate = { viewModel.installAppUpdateOrRequestPermission() }, onOpenDataDeletion = { openExternalUrl(context, ACCOUNT_DELETION_URL) } ) + "storage" -> StorageSettings( + metadataSize = uiState.metadataCacheSize, + artworkSize = uiState.artworkCacheSize, + isClearing = uiState.isClearingCache, + focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, + onClearMetadata = { viewModel.clearMetadataCache() }, + onClearArtwork = { viewModel.clearArtworkCache() }, + onClearAll = { viewModel.clearAllStorage() } + ) } } } @@ -4319,6 +4332,7 @@ private fun tvSettingsSectionTitle(section: String): String { "catalogs" -> stringResource(R.string.catalogs) "stremio" -> stringResource(R.string.addons) "accounts" -> stringResource(R.string.accounts) + "storage" -> "Storage" else -> section.replaceFirstChar { it.uppercase() } } } @@ -4337,6 +4351,7 @@ private fun tvSettingsSectionDescription(section: String): String { "catalogs" -> "Discover, rename, order and remove home rows and list catalogs." "stremio" -> "Manage third-party addons used for catalog and source discovery." "accounts" -> "Cloud sync, Trakt connection, app updates and account controls." + "storage" -> "Manage local database cache, artwork assets, and application storage." else -> "Configure ARVIO for this profile." } } @@ -4385,6 +4400,10 @@ private fun tvSettingsSectionPills( if (uiState.isTraktAuthenticated) "Trakt connected" else "Trakt off", if (uiState.isForceCloudSyncing) "Syncing" else "Ready" ) + "storage" -> listOf( + "DB ${uiState.metadataCacheSize}", + "Art ${uiState.artworkCacheSize}" + ) else -> emptyList() } } @@ -7324,6 +7343,81 @@ private fun AccountsSettings( } } +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +private fun StorageSettings( + metadataSize: String, + artworkSize: String, + isClearing: Boolean, + focusedIndex: Int, + onClearMetadata: () -> Unit, + onClearArtwork: () -> Unit, + onClearAll: () -> Unit +) { + Column { + if (LocalDeviceType.current.isTouchDevice()) { + Text( + text = "Storage", + style = ArflixTypography.sectionTitle, + color = TextPrimary, + modifier = Modifier.padding(bottom = 24.dp) + ) + } + + SettingsRow( + icon = Icons.Default.Delete, + title = "Clear Database Metadata", + subtitle = "Remove cached movies, series, episodes, cast, and reviews.", + value = metadataSize, + isFocused = focusedIndex == 0, + onClick = onClearMetadata, + modifier = Modifier.settingsFocusSlot(0) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsRow( + icon = Icons.Default.Delete, + title = "Clear Cached Artwork", + subtitle = "Delete cached images, posters, and backdrops from disk.", + value = artworkSize, + isFocused = focusedIndex == 1, + onClick = onClearArtwork, + modifier = Modifier.settingsFocusSlot(1) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsRow( + icon = Icons.Default.Delete, + title = "Clear All Cached Data", + subtitle = "Wipe the entire metadata database and all cached image assets.", + value = "", + isFocused = focusedIndex == 2, + onClick = onClearAll, + modifier = Modifier.settingsFocusSlot(2) + ) + + if (isClearing) { + Spacer(modifier = Modifier.height(24.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + LoadingIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Clearing cache...", + style = ArflixTypography.body.copy(fontSize = 14.sp), + color = TextSecondary + ) + } + } + } +} + + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun AccountActionRow( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 2a2b369c..ad4b4821 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -198,7 +198,10 @@ data class SettingsUiState( val subtitleAiModel: SubtitleAiModel = SubtitleAiModel.GROQ_LLAMA_70B, val subtitleRemoveHearingImpaired: Boolean = true, val aiKeyServerState: AiKeyServerState = AiKeyServerState(), - val smoothScrolling: Boolean = true + val smoothScrolling: Boolean = true, + val metadataCacheSize: String = "0 B", + val artworkCacheSize: String = "0 B", + val isClearingCache: Boolean = false ) @HiltViewModel @@ -354,6 +357,7 @@ class SettingsViewModel @Inject constructor( init { loadSettings() + loadStorageSizes() observeProfileChanges() observeAddons() observeTorrServer() @@ -2995,6 +2999,73 @@ class SettingsViewModel @Inject constructor( } } + fun loadStorageSizes() { + viewModelScope.launch { + val metaSize = mediaRepository.getMetadataCacheSizeAsync() + val artSize = mediaRepository.getArtworkCacheSizeAsync() + _uiState.value = _uiState.value.copy( + metadataCacheSize = formatBytes(metaSize), + artworkCacheSize = formatBytes(artSize) + ) + } + } + + private fun formatBytes(bytes: Long): String { + if (bytes <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB") + val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt() + val num = bytes / Math.pow(1024.0, digitGroups.toDouble()) + return String.format(Locale.US, "%.1f %s", num, units[digitGroups]) + } + + fun clearMetadataCache() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isClearingCache = true) + runCatching { + mediaRepository.clearMetadataCache() + } + loadStorageSizes() + _uiState.value = _uiState.value.copy( + isClearingCache = false, + toastMessage = "Metadata cache cleared successfully", + toastType = ToastType.SUCCESS + ) + } + } + + fun clearArtworkCache() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isClearingCache = true) + runCatching { + mediaRepository.clearArtworkCacheAsync() + } + loadStorageSizes() + _uiState.value = _uiState.value.copy( + isClearingCache = false, + toastMessage = "Artwork cache cleared successfully", + toastType = ToastType.SUCCESS + ) + } + } + + fun clearAllStorage() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isClearingCache = true) + runCatching { + mediaRepository.clearMetadataCache() + } + runCatching { + mediaRepository.clearArtworkCacheAsync() + } + loadStorageSizes() + _uiState.value = _uiState.value.copy( + isClearingCache = false, + toastMessage = "All cached data cleared successfully", + toastType = ToastType.SUCCESS + ) + } + } + override fun onCleared() { super.onCleared() traktPollingJob?.cancel() diff --git a/app/src/main/kotlin/com/arflix/tv/worker/CacheRefreshWorker.kt b/app/src/main/kotlin/com/arflix/tv/worker/CacheRefreshWorker.kt new file mode 100644 index 00000000..f142be8a --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/worker/CacheRefreshWorker.kt @@ -0,0 +1,90 @@ +package com.arflix.tv.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.arflix.tv.data.local.CacheDao +import com.arflix.tv.data.local.CachePolicyManager +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.MediaRepository +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Background worker to refresh stale cached metadata periodically (e.g. daily). + * Fetches fresh details for media items that are close to expiration or are stale, + * and purges extremely old cache entries. + */ +class CacheRefreshWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface CacheRefreshWorkerEntryPoint { + fun mediaRepository(): MediaRepository + fun cacheDao(): CacheDao + } + + private val deps: CacheRefreshWorkerEntryPoint by lazy { + EntryPointAccessors.fromApplication( + applicationContext, + CacheRefreshWorkerEntryPoint::class.java + ) + } + + companion object { + const val TAG = "CacheRefreshWorker" + const val WORK_NAME = "cache_refresh_worker" + const val REFRESH_INTERVAL_HOURS = 24L + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + val mediaRepository = deps.mediaRepository() + val cacheDao = deps.cacheDao() + + // 1. Purge extremely old stale items to keep database size under control + // Max TTLs + val oldestAllowedMedia = System.currentTimeMillis() - CachePolicyManager.MOVIE_TTL_MS + val oldestAllowedCast = System.currentTimeMillis() - CachePolicyManager.CAST_TTL_MS + val oldestAllowedEpisodes = System.currentTimeMillis() - CachePolicyManager.EPISODES_TTL_MS + + cacheDao.deleteStaleMediaItems(oldestAllowedMedia) + cacheDao.deleteStaleCastMembers(oldestAllowedCast) + cacheDao.deleteStaleEpisodes(oldestAllowedEpisodes) + + // 2. Refresh recently accessed stale items to preemptively cache them (max 20 items per run) + // Query media items that have updatedAt older than their TTL + // We use TV TTL as a conservative threshold for staled items. + val tvStaleTime = System.currentTimeMillis() - CachePolicyManager.TV_TTL_MS + val staleItems = cacheDao.getStaleMediaItems(staleTime = tvStaleTime, limit = 20) + + for (cachedItem in staleItems) { + try { + val mediaType = MediaType.valueOf(cachedItem.mediaType) + if (mediaType == MediaType.MOVIE) { + mediaRepository.getMovieDetails(cachedItem.id, forceRefresh = true) + } else { + mediaRepository.getTvDetails(cachedItem.id, forceRefresh = true) + } + } catch (e: Exception) { + // Ignore errors for individual items to let other items refresh + } + } + + Result.success() + } catch (e: Exception) { + if (runAttemptCount < 3) { + Result.retry() + } else { + Result.failure() + } + } + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/local/CacheTests.kt b/app/src/test/kotlin/com/arflix/tv/data/local/CacheTests.kt new file mode 100644 index 00000000..719f1e9f --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/local/CacheTests.kt @@ -0,0 +1,433 @@ +package com.arflix.tv.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.arflix.tv.data.api.* +import com.arflix.tv.data.model.* +import com.arflix.tv.data.repository.* +import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import io.mockk.* +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +class CacheTests { + + private lateinit var db: AppDatabase + private lateinit var cacheDao: CacheDao + private val gson = Gson() + + // MediaRepository dependencies + private lateinit var tmdbApi: TmdbApi + private lateinit var traktRepository: TraktRepository + private lateinit var traktApi: TraktApi + private lateinit var okHttpClient: OkHttpClient + private lateinit var streamRepository: StreamRepository + private lateinit var homeServerRepository: HomeServerRepository + private lateinit var mediaRepository: MediaRepository + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + cacheDao = db.cacheDao() + + tmdbApi = mockk(relaxed = true) + traktRepository = mockk(relaxed = true) + traktApi = mockk(relaxed = true) + okHttpClient = OkHttpClient() + streamRepository = mockk(relaxed = true) + homeServerRepository = mockk(relaxed = true) + + mediaRepository = MediaRepository( + tmdbApi = tmdbApi, + traktRepository = traktRepository, + traktApi = traktApi, + okHttpClient = okHttpClient, + streamRepository = streamRepository, + homeServerRepository = homeServerRepository, + cacheDao = cacheDao, + context = context + ) + } + + @After + @Throws(IOException::class) + fun tearDown() { + db.close() + } + + // ========================================== + // 1. CachePolicyManager Tests + // ========================================== + + @Test + fun cachePolicyManager_isMediaItemFresh_worksCorrectly() { + val freshMovie = CachedMediaItem( + id = 1, + mediaType = MediaType.MOVIE.name, + title = "Movie 1", + updatedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6) + ) + val staleMovie = CachedMediaItem( + id = 2, + mediaType = MediaType.MOVIE.name, + title = "Movie 2", + updatedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(8) + ) + val freshTv = CachedMediaItem( + id = 3, + mediaType = MediaType.TV.name, + title = "TV Show 1", + updatedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + ) + val staleTv = CachedMediaItem( + id = 4, + mediaType = MediaType.TV.name, + title = "TV Show 2", + updatedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(4) + ) + + assertThat(CachePolicyManager.isMediaItemFresh(freshMovie)).isTrue() + assertThat(CachePolicyManager.isMediaItemFresh(staleMovie)).isFalse() + assertThat(CachePolicyManager.isMediaItemFresh(freshTv)).isTrue() + assertThat(CachePolicyManager.isMediaItemFresh(staleTv)).isFalse() + + // forceRefresh should always result in stale + assertThat(CachePolicyManager.isMediaItemFresh(freshMovie, forceRefresh = true)).isFalse() + assertThat(CachePolicyManager.isMediaItemFresh(freshTv, forceRefresh = true)).isFalse() + } + + @Test + fun cachePolicyManager_otherEntitiesFreshness_worksCorrectly() { + val freshCastTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(13) + val staleCastTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(15) + + val freshEpisodeTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val staleEpisodeTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(4) + + val freshReviewTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6) + val staleReviewTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(8) + + val freshCollectionTime = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(23) + val staleCollectionTime = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(25) + + assertThat(CachePolicyManager.isCastFresh(freshCastTime)).isTrue() + assertThat(CachePolicyManager.isCastFresh(staleCastTime)).isFalse() + + assertThat(CachePolicyManager.isEpisodesFresh(freshEpisodeTime)).isTrue() + assertThat(CachePolicyManager.isEpisodesFresh(staleEpisodeTime)).isFalse() + + assertThat(CachePolicyManager.isReviewsFresh(freshReviewTime)).isTrue() + assertThat(CachePolicyManager.isReviewsFresh(staleReviewTime)).isFalse() + + assertThat(CachePolicyManager.isCollectionFresh(freshCollectionTime)).isTrue() + assertThat(CachePolicyManager.isCollectionFresh(staleCollectionTime)).isFalse() + + // forceRefresh + assertThat(CachePolicyManager.isCastFresh(freshCastTime, forceRefresh = true)).isFalse() + assertThat(CachePolicyManager.isEpisodesFresh(freshEpisodeTime, forceRefresh = true)).isFalse() + assertThat(CachePolicyManager.isReviewsFresh(freshReviewTime, forceRefresh = true)).isFalse() + assertThat(CachePolicyManager.isCollectionFresh(freshCollectionTime, forceRefresh = true)).isFalse() + } + + // ========================================== + // 2. CacheDao Tests + // ========================================== + + @Test + fun cacheDao_insertAndGetMediaItem_savesCorrectly() = runTest { + val cachedItem = CachedMediaItem( + id = 100, + mediaType = MediaType.MOVIE.name, + title = "Test Movie" + ) + cacheDao.insertMediaItem(cachedItem) + + val retrieved = cacheDao.getMediaItem(100, MediaType.MOVIE.name) + assertThat(retrieved).isNotNull() + assertThat(retrieved?.title).isEqualTo("Test Movie") + } + + @Test + fun cacheDao_clearAllMetadata_clearsAllTables() = runTest { + val cachedItem = CachedMediaItem( + id = 100, + mediaType = MediaType.MOVIE.name, + title = "Test Movie" + ) + cacheDao.insertMediaItem(cachedItem) + + val cachedEpisode = CachedEpisode( + id = 101, + tvId = 200, + seasonNumber = 1, + episodeNumber = 1, + name = "Pilot" + ) + cacheDao.insertEpisodes(listOf(cachedEpisode)) + + // Verify inserted + assertThat(cacheDao.getMediaItem(100, MediaType.MOVIE.name)).isNotNull() + assertThat(cacheDao.getEpisodes(200, 1)).isNotEmpty() + + // Clear all + cacheDao.clearAllMetadata() + + // Verify empty + assertThat(cacheDao.getMediaItem(100, MediaType.MOVIE.name)).isNull() + assertThat(cacheDao.getEpisodes(200, 1)).isEmpty() + } + + @Test + fun cacheDao_saveTvSeason_overwritesCorrectly() = runTest { + val tvId = 123 + val seasonNum = 1 + + val ep1 = CachedEpisode(id = 1, tvId = tvId, seasonNumber = seasonNum, episodeNumber = 1, name = "First") + val ep2 = CachedEpisode(id = 2, tvId = tvId, seasonNumber = seasonNum, episodeNumber = 2, name = "Second") + + cacheDao.saveTvSeason(tvId, seasonNum, listOf(ep1, ep2)) + + var episodes = cacheDao.getEpisodes(tvId, seasonNum) + assertThat(episodes.size).isEqualTo(2) + + // Save new list, should overwrite old ones + val ep3 = CachedEpisode(id = 3, tvId = tvId, seasonNumber = seasonNum, episodeNumber = 3, name = "Third") + cacheDao.saveTvSeason(tvId, seasonNum, listOf(ep3)) + + episodes = cacheDao.getEpisodes(tvId, seasonNum) + assertThat(episodes.size).isEqualTo(1) + assertThat(episodes.first().name).isEqualTo("Third") + } + + // ========================================== + // 3. MediaRepository Cache Flow Tests + // ========================================== + + @Test + fun mediaRepository_getMovieDetails_cacheMiss_networkSuccess() = runTest { + val movieId = 404 + val tmdbMovieDetails = TmdbMovieDetails( + id = movieId, + title = "Network Movie", + overview = "Overview of Network Movie", + releaseDate = "2024-01-01" + ) + + coEvery { tmdbApi.getMovieDetails(movieId, any(), any()) } returns tmdbMovieDetails + coEvery { tmdbApi.getMovieExternalIds(movieId, any()) } returns TmdbExternalIds("tt123", 456) + + // Make call + val result = mediaRepository.getMovieDetails(movieId) + + // Verify it returned network data + assertThat(result.id).isEqualTo(movieId) + assertThat(result.title).isEqualTo("Network Movie") + + // Verify it cached the entity in the database + val dbCached = cacheDao.getMediaItem(movieId, MediaType.MOVIE.name) + assertThat(dbCached).isNotNull() + assertThat(dbCached?.title).isEqualTo("Network Movie") + } + + @Test + fun mediaRepository_getMovieDetails_cacheHit_fresh_skipsNetwork() = runTest { + val movieId = 500 + val cachedItem = CachedMediaItem( + id = movieId, + mediaType = MediaType.MOVIE.name, + title = "Cached Movie", + updatedAt = System.currentTimeMillis() // Fresh + ) + cacheDao.insertMediaItem(cachedItem) + + // Call repository + val result = mediaRepository.getMovieDetails(movieId) + + // Verify it returned cache without network call + assertThat(result.title).isEqualTo("Cached Movie") + coVerify(exactly = 0) { tmdbApi.getMovieDetails(any(), any(), any()) } + } + + @Test + fun mediaRepository_getMovieDetails_cacheHit_stale_networkSuccess() = runTest { + val movieId = 500 + val cachedItem = CachedMediaItem( + id = movieId, + mediaType = MediaType.MOVIE.name, + title = "Cached Movie", + updatedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) // Stale + ) + cacheDao.insertMediaItem(cachedItem) + + val tmdbMovieDetails = TmdbMovieDetails( + id = movieId, + title = "Network Movie" + ) + coEvery { tmdbApi.getMovieDetails(movieId, any(), any()) } returns tmdbMovieDetails + + // Call repository + val result = mediaRepository.getMovieDetails(movieId) + + // Verify network details returned and updated in DB + assertThat(result.title).isEqualTo("Network Movie") + coVerify(exactly = 1) { tmdbApi.getMovieDetails(movieId, any(), any()) } + + val dbCached = cacheDao.getMediaItem(movieId, MediaType.MOVIE.name) + assertThat(dbCached?.title).isEqualTo("Network Movie") + assertThat(dbCached?.updatedAt).isGreaterThan(System.currentTimeMillis() - 5000) + } + + @Test + fun mediaRepository_getMovieDetails_cacheHit_stale_networkFailure_fallsBackToCache() = runTest { + val movieId = 600 + val cachedItem = CachedMediaItem( + id = movieId, + mediaType = MediaType.MOVIE.name, + title = "Stale Movie Fallback", + updatedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) // Stale + ) + cacheDao.insertMediaItem(cachedItem) + + // Mock network error + coEvery { tmdbApi.getMovieDetails(movieId, any(), any()) } throws RuntimeException("Network Offline") + + // Call repository + val result = mediaRepository.getMovieDetails(movieId) + + // Verify it returned the stale cache data instead of crashing + assertThat(result.title).isEqualTo("Stale Movie Fallback") + coVerify(exactly = 1) { tmdbApi.getMovieDetails(movieId, any(), any()) } + } + + @Test + fun mediaRepository_getTvDetails_cacheMiss_networkSuccess() = runTest { + val tvId = 700 + val tmdbTvDetails = TmdbTvDetails( + id = tvId, + name = "Network Show", + overview = "Show overview" + ) + + coEvery { tmdbApi.getTvDetails(tvId, any(), any()) } returns tmdbTvDetails + + val result = mediaRepository.getTvDetails(tvId) + + assertThat(result.id).isEqualTo(tvId) + assertThat(result.title).isEqualTo("Network Show") + + val dbCached = cacheDao.getMediaItem(tvId, MediaType.TV.name) + assertThat(dbCached).isNotNull() + assertThat(dbCached?.title).isEqualTo("Network Show") + } + + @Test + fun mediaRepository_getSeasonEpisodes_cacheMiss_networkSuccess() = runTest { + val tvId = 800 + val seasonNum = 1 + val tmdbSeason = TmdbSeasonDetails( + id = 999, + seasonNumber = seasonNum, + name = "Season 1", + episodes = listOf( + TmdbEpisode(id = 1, episodeNumber = 1, seasonNumber = seasonNum, name = "Ep 1"), + TmdbEpisode(id = 2, episodeNumber = 2, seasonNumber = seasonNum, name = "Ep 2") + ) + ) + + coEvery { tmdbApi.getTvSeason(tvId, seasonNum, any(), any()) } returns tmdbSeason + + val result = mediaRepository.getSeasonEpisodes(tvId, seasonNum) + + assertThat(result.size).isEqualTo(2) + assertThat(result[0].name).isEqualTo("Ep 1") + + val dbEpisodes = cacheDao.getEpisodes(tvId, seasonNum) + assertThat(dbEpisodes.size).isEqualTo(2) + assertThat(dbEpisodes[0].name).isEqualTo("Ep 1") + } + + @Test + fun mediaRepository_getCast_cacheHit_fresh_skipsNetwork() = runTest { + val mediaId = 900 + val mediaType = MediaType.MOVIE + val cachedCast = CachedCastMember( + id = 1, + mediaId = mediaId, + mediaType = mediaType.name, + name = "Actor 1", + character = "Hero", + profilePath = null, + updatedAt = System.currentTimeMillis() + ) + cacheDao.saveCredits(mediaId, mediaType.name, listOf(cachedCast)) + + val result = mediaRepository.getCast(mediaType, mediaId) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().name).isEqualTo("Actor 1") + coVerify(exactly = 0) { tmdbApi.getCredits(any(), any(), any(), any()) } + } + + @Test + fun mediaRepository_getSimilar_cacheMiss_networkSuccess() = runTest { + val mediaId = 1000 + val mediaType = MediaType.MOVIE + val similarResponse = TmdbListResponse( + results = listOf( + TmdbMediaItem(id = 1001, title = "Similar 1", mediaType = "movie"), + TmdbMediaItem(id = 1002, title = "Similar 2", mediaType = "movie") + ) + ) + + coEvery { tmdbApi.getRecommendations("movie", mediaId, any(), any()) } returns similarResponse + + val result = mediaRepository.getSimilar(mediaType, mediaId) + + assertThat(result.size).isEqualTo(2) + assertThat(result[0].title).isEqualTo("Similar 1") + + val dbSimilar = cacheDao.getSimilarItems(mediaId, mediaType.name) + assertThat(dbSimilar.size).isEqualTo(2) + } + + @Test + fun mediaRepository_getReviews_cacheMiss_networkSuccess() = runTest { + val mediaId = 1100 + val mediaType = MediaType.MOVIE + val reviewsResponse = TmdbReviewsResponse( + results = listOf( + TmdbReview( + id = "rev1", + author = "Critic 1", + content = "Great show!", + authorDetails = TmdbAuthorDetails(username = "critic1", rating = 9.0f) + ) + ) + ) + + coEvery { tmdbApi.getReviews("movie", mediaId, any(), any()) } returns reviewsResponse + + val result = mediaRepository.getReviews(mediaType, mediaId) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().content).isEqualTo("Great show!") + + val dbReviews = cacheDao.getReviews(mediaId, mediaType.name) + assertThat(dbReviews.size).isEqualTo(1) + assertThat(dbReviews.first().content).isEqualTo("Great show!") + } +} diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..a87d08d9 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,160 @@ +# Supabase Edge Functions API Documentation + +This guide describes the API contracts, request payloads, and response models for the Supabase Edge Functions under the `supabase/functions/` directory. + +--- + +## 1. Metadata API Proxies + +These functions act as secure relays between the client applications and external metadata providers. + +### A. TMDB Proxy (`/functions/tmdb-proxy`) +- **Purpose:** Fetches metadata, posters, and details from TMDB without shipping API keys in the client. +- **Request Headers:** + - `Authorization: Bearer ` (Requires client authentication) +- **Parameters:** Relays normal TMDB query params (e.g. `/movie/{id}`, `/search/multi`). + +### B. Trakt Proxy (`/functions/trakt-proxy`) +- **Purpose:** Coordinates profile-isolated Trakt authentication and synchronizes watchlists. +- **Request Headers:** + - `Authorization: Bearer ` +- **Parameters:** Passes access code parameters and redirects to oauth login. + +--- + +## 2. TV Code Pairing Authentication Flow + +Because typing emails and passwords is slow on TVs, ARVIO implements a numeric pairing code authentication flow. + +```mermaid +sequenceDiagram + participant TV as Android TV Client + participant API as Supabase Functions + participant Web as Phone / Web Client + TV->>API: POST /tv-auth-start (Device UUID) + API-->>TV: Return Pairing Code (e.g. 556677) & Expire Time + TV->>API: GET /tv-auth-status (Device UUID) - Polls every 5s + Web->>API: POST /tv-auth-approve (Pairing Code + User JWT) + Note over API: Matches code and links TV UUID to User account + API-->>Web: Success Response + API-->>TV: Return User Token (On next poll status check) + TV->>API: POST /tv-auth-complete (Acknowledge) +``` + +### Endpoints Details + +#### 1. TV Auth Start (`/functions/tv-auth-start`) +- **Method:** `POST` +- **Payload:** + ```json + { + "device_id": "unique-tv-uuid-12345", + "device_name": "Living Room Android TV" + } + ``` +- **Response:** + ```json + { + "pairing_code": "489211", + "expires_at": "2026-06-02T14:30:00Z" + } + ``` + +#### 2. TV Auth Status (`/functions/tv-auth-status`) +- **Method:** `GET` +- **Query Parameters:** `?device_id=unique-tv-uuid-12345` +- **Response (Pending approval):** + ```json + { + "status": "pending" + } + ``` +- **Response (Approved):** + ```json + { + "status": "approved", + "access_token": "eyJhbGciOiJIUzI1NiIsIn...", + "refresh_token": "ref-tok-abc12345" + } + ``` + +#### 3. TV Auth Approve (`/functions/tv-auth-approve`) +- **Method:** `POST` +- **Request Headers:** `Authorization: Bearer ` +- **Payload:** + ```json + { + "pairing_code": "489211" + } + ``` +- **Response:** + ```json + { + "status": "success", + "message": "Device successfully authenticated" + } + ``` + +#### 4. TV Auth Complete (`/functions/tv-auth-complete`) +- **Method:** `POST` +- **Payload:** + ```json + { + "device_id": "unique-tv-uuid-12345" + } + ``` +- **Response:** + ```json + { + "status": "acknowledged" + } + ``` + +--- + +## 3. Cloud Auth & Email Handlers + +These edge functions manage email validation and credential recoveries. + +- **`cloud-auth-email`:** Triggers transactional verification links on registration. +- **`cloud-auth-reset`:** Triggers password recovery links. + +--- + +## 4. App Usage Events (`/functions/app-usage-event`) + +Provides simple analytics to help prioritize developer effort on popular device classes. + +- **Method:** `POST` +- **Request Headers:** `Authorization: Bearer ` +- **Payload:** + ```json + { + "event_type": "app_launch", + "device_class": "tv", + "os_version": "Android 12 (API 31)", + "app_version": "1.9.94" + } + ``` +- **Response:** + ```json + { + "status": "logged" + } + ``` + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./docs/configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation (this document). +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..edbf838d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,161 @@ +# ARVIO Architecture Documentation + +This document describes the high-level architecture, module breakdown, internal dependency flows, data lifecycles, and backend integrations of the ARVIO codebase. + +--- + +## 1. High-Level System Overview + +ARVIO is structured as a decoupled multi-platform project with a shared backend service layer. + +```mermaid +graph TD + subgraph Client Apps + Android["Android TV/Mobile App (:app)"] + iOS["iOS App (iosApp)"] + end + subgraph Cloud Services + Supabase["Supabase (Edge Functions & PostgreSQL)"] + Netlify["Netlify Auth site (Web Portal)"] + end + subgraph Third-Party integrations + TMDB["TMDB API (Metadata)"] + Trakt["Trakt API (Sync & Watchlists)"] + IPTV["IPTV M3U / EPG Streams"] + Jellyfin["Jellyfin / Plex / Emby Servers"] + end + + Android -->|GraphQL/REST| Supabase + iOS -->|REST| Supabase + Supabase -->|Secure Proxy| TMDB + Supabase -->|Secure Proxy| Trakt + Android -->|Direct HTTP/HLS| IPTV + Android -->|Direct API| Jellyfin + Netlify -->|Auth Sync| Supabase +``` + +--- + +## 2. Core Android Modules & Folder Responsibilities + +The primary Android application resides inside the `app/src/main/kotlin/com/arflix/tv/` directory. + +### Module Breakdown +- **[ArflixApplication.kt](../app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt):** Main application class. Initializes Sentry, Hilt Dependency Injection, and registers WorkManager periodic sync workers. +- **[MainActivity.kt](../app/src/main/kotlin/com/arflix/tv/MainActivity.kt):** Single entry point activity. Sets up the Compose content tree, handles deep links, and manages fullscreen system UI visibility. +- **`ui/`:** Implements UI components and screens. Includes `theme/` definitions, custom components (e.g. `Sidebar.kt`, `MediaCard.kt`), and features grouped by page flow (home, search, tv, player). +- **`data/`:** Encapsulates the data access layer: + - `model/`: Domain models (e.g. `Profile.kt`, `CatalogModels.kt`). + - `api/`: Retrofit service endpoints (e.g. `TmdbApi.kt`, `TraktApi.kt`). + - `repository/`: Single source of truth repositories coordinating local cache and remote fetches (e.g. `CloudSyncRepository.kt`, `IptvRepository.kt`). +- **`di/`:** Defines Hilt Dependency Injection modules for providing singleton network, database, and repository instances. +- **`navigation/`:** Manages Compose Navigation graph mapping destinations (Home, TV, Player, Settings) and passing route arguments. +- **`network/`:** Network monitors and interceptors (e.g. `ApiProxyInterceptor.kt`) that append tokens or rewrite URLs for Supabase proxies. +- **`updater/`:** Controls sideload app updates, including APK downloading, file provider sharing, and triggering package installers. +- **`util/`:** Core utility files, including frame-rate matching helpers, subtitle scoring logic, and logging systems. +- **`worker/`:** Background services using Android WorkManager for scheduling period tasks. + +--- + +## 3. iOS SwiftUI Shell Architecture + +The additive iOS target resides under `iosApp/`: +- **[ARVIOApp.swift](../iosApp/ARVIO/ARVIOApp.swift):** Entry point of the application. +- **`AuthService.swift` / `CloudSyncService.swift`:** Swift clients that consume Supabase REST endpoints for profile restoration and settings sync. +- **`AddonService.swift`:** Manages addon discovery and resolves stream URLs. +- **`HomeView.swift` / `SettingsView.swift`:** SwiftUI views rendering reactive lists. + +--- + +## 4. Internal Dependency Flow + +Data flows reactive-style from database sources up to the UI components. + +```mermaid +graph LR + API["API Data Sources"] --> Repo["Repositories"] + LocalDB["Local Cache / DataStore"] --> Repo + Repo --> ViewModel["ViewModels"] + ViewModel --> UI["Jetpack Compose UI / SwiftUI"] +``` + +1. **API & Local Cache:** Repositories combine API calls with local SQLite or DataStore preferences. +2. **State Sharing:** Repositories publish Kotlin `Flow`s representing loading, success, or error states. +3. **ViewModel Consumption:** ViewModels collect flows, apply map transformations, and expose state. +4. **Reactive UI:** Compositions collect ViewModel state (using `collectAsStateWithLifecycle`) to trigger recomposition when the data updates. + +--- + +## 5. Configuration & Sync Lifecycles + +### Local Storage & Settings +Local settings are stored using Jetpack DataStore Preferences in [DataStores.kt](../app/src/main/kotlin/com/arflix/tv/util/DataStores.kt). This includes subtitle defaults, display layout selections, active profiles, and DNS parameters. + +### Cloud Synchronization Flow +1. **Trigger:** Profile change, app resume, or manual sync trigger in Settings. +2. **Fetch:** [CloudSyncCoordinator.kt](../app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt) pulls the latest profile snapshot from Supabase. +3. **Merge:** System compares timestamps and applies incoming updates to local DataStore settings and IPTV favorites. +4. **WebSocket Sync:** When active, [RealtimeSyncManager.kt](../app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt) maintains a persistent WebSocket connection to broadcast settings updates in real time to other paired devices. + +--- + +## 6. Critical Architectural Utilities + +- **[FrameRateUtils.kt](../app/src/main/kotlin/com/arflix/tv/util/FrameRateUtils.kt):** Matches the physical TV display rate to the video stream rate to eliminate stuttering during ExoPlayer playback. +- **[SubtitleScoring.kt](../app/src/main/kotlin/com/arflix/tv/util/SubtitleScoring.kt):** Scores and ranks available subtitle tracks based on filename keywords (e.g. matching release team) to present the best option first. +- **[OkHttpProvider.kt](../app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt):** Builds the standard HTTP client, injecting interceptors for API credentials and setting custom timeouts for streaming feeds. + +--- + +## 7. Offline Caching Subsystem (Metadata & Artwork) + +ARVIO implements a robust local cache-first architecture for TMDB metadata and media artwork to speed up navigation, decrease API requests, and enable seamless offline or low-connectivity browsing. + +### Data Flow & Strategy +When a detail page or media list is loaded: +1. **In-Memory Cache:** The repository checks transient in-memory maps for hot-access records (with 5-minute TTL). +2. **Database Cache:** If missed in memory, it queries the local Room Database. If the database entry is fresh (according to `CachePolicyManager` TTL policies), it returns it immediately and prefetches dependencies. +3. **Network Fetch:** If stale or missing, the repository fetches details from TMDB (or Edge Proxy) asynchronously. +4. **Cache Sync:** Upon a successful fetch, the new metadata is stored back in the Room Database, updating memory caches. +5. **Offline Fallback:** If the network request fails but a stale cached record is present in the database, the system gracefully falls back to displaying the stale record. + +### Local Cache Entities +The caching layer leverages **Room Database** to persist metadata across the following tables: +- **`media_items`:** Movie and TV Show details (popularity, original language, runtimes, rating, overview, etc.). +- **`cast_members`:** Actor details associated with a TV show or movie. +- **`episodes`:** Individual episodes for TV seasons. +- **`similar_items`:** Inter-item recommendation maps for detail sections. +- **`reviews`:** Textual user reviews from TMDB. +- **`collection_refs` & `search_history`:** Offline reference tables for catalog collections and queries. + +### TTL Freshness Policies +Expiration policies are centrally defined in `CachePolicyManager`: +- **Movies & Reviews:** 7 Days +- **TV Shows & Episodes:** 3 Days +- **Cast Details:** 14 Days +- **Franchise Collections:** 1 Day + +### Background Refresh & Pruning +A periodic task (`CacheRefreshWorker`) runs under constraints of **charging** and **unmetered network connection** using WorkManager: +1. It queries the database for stale media items. +2. It fetches fresh details for the most recently accessed items to keep details ready. +3. It prunes old metadata from the tables to manage storage size. + +### Artwork Caching +All images and artwork are cached using Coil's automated Disk Cache. Custom hooks under **Settings -> Storage** permit querying the directory size and clearing stored images independently. + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows (this document). +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..c4af5bdd --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,110 @@ +# Configuration and Secrets Guide + +This document explains how ARVIO manages secrets, API credentials, and release signing certificates. + +--- + +## 1. Secrets Management Architecture + +ARVIO utilizes the **Secrets Gradle Plugin** to load local configuration parameters dynamically. + +- **[secrets.defaults.properties](../secrets.defaults.properties):** Checked into Git. Contains default dummy values and placeholder parameters to ensure the project compiles out-of-the-box. +- **`secrets.properties`:** Not checked into Git. Used locally to override defaults with real API tokens and database keys. + +When compiling, keys defined in `secrets.properties` are injected by Gradle as fields in `BuildConfig` or reference strings. + +--- + +## 2. API Proxy Routing vs. Direct Keys + +To prevent credentials (TMDB / Trakt API keys) from being extracted from client binaries, release builds route metadata calls through Supabase Edge Function proxies. + +```mermaid +sequenceDiagram + participant Client as ARVIO Client + participant Proxy as Supabase Proxy + participant Service as TMDB / Trakt API + Client->>Proxy: Request (User JWT / Anon Header) + Note over Proxy: Appends secret key stored in Supabase secrets + Proxy->>Service: Authenticated Request + Service->>Proxy: JSON Response + Proxy->>Client: Normalized Media Metadata +``` + +### Local Direct Configuration (Developer Mode) +If you want to test API features without setting up a full Supabase proxy cluster: +1. Provide TMDB and Trakt keys directly in your local `secrets.properties`: + ```properties + TMDB_API_KEY=your_actual_tmdb_key + TRAKT_CLIENT_ID=your_actual_trakt_id + TRAKT_CLIENT_SECRET=your_actual_trakt_secret + ``` +2. The app's repository classes will detect these keys and automatically fall back to calling TMDB/Trakt endpoints directly instead of routing through the proxy. + +--- + +## 3. Configuration Fields Reference + +Add or modify these values in your local `secrets.properties`: + +| Key Name | Purpose | Example / Required Format | +|----------|---------|---------------------------| +| `SUPABASE_URL` | Endpoint URL for profile sync databases. | `https://xzy.supabase.co` | +| `SUPABASE_ANON_KEY` | Public access token for Supabase functions. | `eyJhbGciOiJIUzI1NiIsIn...` | +| `GOOGLE_WEB_CLIENT_ID` | Web Client ID for configuring Google authentication on Android TV. | `123-abc.apps.googleusercontent.com` | +| `SENTRY_DSN` | Destination URL for Sentry logging. | `https://key@sentry.io/project` (Set `disabled` to skip) | + +--- + +## 4. Release Keystore signing Setup + +Android release builds require signing configurations. + +1. Create a `keystore.properties` in the repository root: + ```properties + storeFile=my-release.keystore + storePassword=keystore_passphrase + keyAlias=my_signing_alias + keyPassword=alias_passphrase + ``` +2. Place your keystore file (e.g. `my-release.keystore`) inside the root directory. +3. Gradle will automatically load these properties when running `:app:assemblePlayRelease` or `:app:assembleSideloadRelease`. + +### Generating a Keystore (For Testing) +To generate a private keystore key locally: +```bash +keytool -genkey -v -keystore testing-release.keystore -alias testing_alias \ + -keyalg RSA -keysize 2048 -validity 10000 +``` + +## 5. Storage and Cache Management Settings + +ARVIO allows users to monitor and clear cache sizes under the **Settings -> System -> Storage** panel. + +### Monitored Paths and Data Sources +- **Metadata Database:** Refers to the Room database file (`arvio_cache.db`). The settings panel computes the actual size of the database file on disk. +- **Artwork Cache:** Refers to Coil's image disk cache located at `cacheDir/image_cache/`. + +### Available Maintenance Actions +The UI provides D-pad friendly actions to perform cache cleaning: +1. **Clear Database Metadata:** Calls the Room database transaction `cacheDao.clearAllMetadata()` to purge all cached movies, shows, episodes, cast, and reviews without deleting preference data. +2. **Clear Cached Artwork:** Clears Coil's disk cache by invoking the Coil `imageLoader`'s disk cache clear method. +3. **Clear All Cached Data:** Performs both operations in sequence. + +The size indicators are dynamically recalculated using a background coroutine upon completing any clear operation. + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./configuration.md) - App parameters and credentials reference (this document). +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./docs/deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..0eae85ee --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,88 @@ +# CI/CD and Deployment Guide + +This document describes the automated CI/CD pipelines, TestFlight release steps, Netlify configurations, and release smoke test checklists. + +--- + +## 1. Automated GitHub Actions Workflows + +We maintain three GitHub Actions workflows in [.github/workflows/](../.github/workflows): + +### A. Build Check (`build-check.yml`) +- **Trigger:** Pull Requests targeting the `main` branch. +- **Action:** + 1. Sets up JDK 17 (Temurin). + 2. Generates a temporary Android signing keystore locally. + 3. Builds the sideload debug variant using Gradle: `./gradlew assembleSideloadDebug --no-daemon`. + +### B. iOS TestFlight Pipeline (`ios-testflight.yml`) +- **Trigger:** Manual (`workflow_dispatch`). +- **Action:** + 1. Installs **XcodeGen** on a macOS runner. + 2. Injects Supabase parameters into `AppConfig.swift`. + 3. Writes App Store Connect signing credentials. + 4. Generates the Xcode project file: `xcodegen generate --spec iosApp/project.yml --project iosApp`. + 5. Decodes and unlocks distribution signing certificates. + 6. Archives the app and exports the `.ipa` package. + 7. Uploads the final bundle to Apple TestFlight. + +### C. iOS Upload Existing IPA (`ios-upload-existing-ipa.yml`) +- **Trigger:** Manual (`workflow_dispatch`) with `ipa_run_id` input. +- **Action:** + 1. Injects App Store Connect credentials on a macOS runner. + 2. Downloads the pre-built `ARVIO-iOS-IPA` artifact from the specified GitHub Actions run. + 3. Uploads the downloaded `.ipa` package to Apple TestFlight. + +--- + +## 2. Release Checks and Smoke Testing + +Before deploying updates or releasing production APKs, run the following verification steps: + +### A. Local Compilations +```bash +./gradlew :app:compilePlayDebugKotlin +./gradlew :app:assemblePlayRelease +./gradlew :app:assembleSideloadRelease +``` + +### B. Manual Smoke Testing Checklist +Before publishing: +- [ ] **Startup:** Verify clean app launch on Android TV and mobile layouts. +- [ ] **Profile Switching:** Confirm settings and lists isolate correctly between profiles. +- [ ] **IPTV Load:** Test category lists reordering and EPG backfill checks. +- [ ] **Playback:** Open streams, select internal/external subtitle tracks, adjust audio sync, and verify frame-rate switches. +- [ ] **Addon Sync:** Add and delete community streaming addons. Verify persistence across restarts. +- [ ] **Cloud Sync:** Sync changes from a mobile device and confirm they are immediately reflected on the TV emulator via WebSockets. + +--- + +## 3. Netlify Auth Site Deployment + +The web auth pairing portal resides in the [netlify-auth-site/](../netlify-auth-site/) directory. + +- **Files:** + - `index.html`: Portal landing and pairing forms. + - `delete-account.html`: Required account data deletion endpoint. + - `netlify.toml`: Deployment and routing settings. +- **Deployment:** + - Connect your GitHub fork to **Netlify**. + - Configure the build base directory to `netlify-auth-site`. + - Set the build command to *empty* (this is a static HTML site). + - Publish the site. Netlify will auto-deploy updates pushed to `main`. + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./docs/configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads (this document). +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..75731de7 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,129 @@ +# Local Development and Compilation Guide + +This document describes build variants, testing procedures, and deployment workflows for the ARVIO codebase. + +--- + +## 1. Android Build Flavors & Types + +ARVIO utilizes two product flavors and three build types configured in [build.gradle.kts](../app/build.gradle.kts). + +### Product Flavors (`flavorDimensions = "distribution"`) +- **`play`:** Targets Google Play. In-app updater checks are disabled. Excludes FFmpeg software decoding libraries to ensure compliance with 16KB system memory alignment constraints in modern Android releases. +- **`sideload`:** Targets direct APK distribution. Self-updating is enabled, and it packs the Jellyfin-built FFmpeg Media3 decoder extension to support DTS/TrueHD/Atmos/HEVC audio/video codecs natively. + +### Build Types +- **`debug`:** Developer builds. Includes debugging symbols, enables logcat output, and disables code minification (R8). +- **`staging`:** Signed with the debug keystore, but applies full R8 optimization flags. Useful to test upgrade continuity over old installations while preserving profile caches. +- **`release`:** Production-ready optimized APK. Must be signed with a private signing key. + +--- + +## 2. Compile and Assemble APKs + +To compile APKs via the Gradle wrapper command line: + +### Play Store Variants +```bash +./gradlew :app:assemblePlayDebug # Outputs debug Play Store APK +./gradlew :app:assemblePlayRelease # Outputs optimized signed Play Store APK +``` + +### Sideload Variants +```bash +./gradlew :app:assembleSideloadDebug # Outputs debug Sideload APK +./gradlew :app:assembleSideloadRelease # Outputs optimized Sideload APK +``` + +APKs are compiled to the output directory: +`app/build/outputs/apk/[play|sideload]/[debug|release]/` + +--- + +## 3. Installing and Running on Emulator or Device + +### Using Gradle (Auto-installation) +Ensure you have a running emulator or a connected device listed via `adb devices`. + +- **Install Play Debug:** + ```bash + ./gradlew :app:installPlayDebug + ``` +- **Install Sideload Debug:** + ```bash + ./gradlew :app:installSideloadDebug + ``` + +--- + +## 4. Connecting and Debugging Android TV via Network ADB + +Android TV and Fire TV devices are usually debugged over Wi-Fi. + +1. Enable **Developer Options** on your TV device: + - Go to **Settings > Device Preferences > About**. + - Click **Build** 7 times. +2. Enable **ADB Debugging** under Developer Options. +3. Find the IP Address of your TV in Network Settings. +4. Establish ADB connection: + ```bash + adb connect :5555 + ``` +5. Install your compiled sideload APK: + ```bash + adb install -r app/build/outputs/apk/sideload/debug/app-sideload-debug.apk + ``` +6. Monitor logcat output: + ```bash + adb logcat -v time | grep ARVIO + ``` + +--- + +## 5. iOS Development Workflow (macOS) + +1. Generate the Xcode project: + ```bash + xcodegen generate --spec iosApp/project.yml --project iosApp + ``` +2. Open the workspace project file: + ```bash + open iosApp/ARVIO.xcodeproj + ``` +3. In Xcode, select your simulator target or provisioned developer device. +4. Press **Cmd + R** to build and run the SwiftUI application. + +--- + +## 6. Running the Test Suite + +We maintain a test suite verifying repositories, models, and utility classes under `app/src/test`. + +### Run Unit Tests +```bash +./gradlew :app:testPlayDebugUnitTest +``` +Test summaries are generated in HTML format: +`app/build/reports/tests/testPlayDebugUnitTest/index.html` + +### Run detekt checks +Verify that your changes match coding styling and static analysis parameters: +```bash +./gradlew detekt +``` + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows (this document). +- [docs/configuration.md](./configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/docs/ios-testflight.md b/docs/ios-testflight.md index f4167aad..d4775acf 100644 --- a/docs/ios-testflight.md +++ b/docs/ios-testflight.md @@ -12,8 +12,7 @@ The Android TV APK remains in `app/` and is not coupled to the iOS target. - `APPLE_TEAM_ID` - `IOS_BUNDLE_ID` -The current workflow uses GitHub's macOS runner and Xcode automatic signing with the App Store Connect API key. -If Apple refuses automatic signing in CI, the workflow will need manual distribution certificate and provisioning profile secrets. +The workflow runs on GitHub's macOS runner. It dynamically generates an App Store distribution certificate and provisioning profile on the fly via the App Store Connect API, installs them to the runner's keychain, and signs the archive manually using those assets. ## Running @@ -23,6 +22,22 @@ The workflow: 1. Installs XcodeGen. 2. Generates `iosApp/ARVIO.xcodeproj`. -3. Archives the app with automatic signing. +3. Archives the app using the dynamically generated manual signing assets. 4. Exports an App Store IPA. 5. Uploads the IPA to TestFlight. + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions (this document). diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 00000000..913fa1f2 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,120 @@ +# Local Environment Setup Guide + +This guide walks you through setting up your local machine to build, run, and test the ARVIO applications. + +--- + +## 1. System Prerequisites + +Choose your target platforms: +- **Android development:** Supported on Windows, macOS, and Linux. +- **iOS development:** Requires macOS and Xcode. + +--- + +## 2. Setting up Java Development Kit (JDK 17) + +ARVIO requires **JDK 17** for Gradle compiles. + +### Install on macOS (via Homebrew) +```bash +brew install openjdk@17 +``` +Configure your shell profile (e.g. `~/.zshrc` or `~/.bash_profile`): +```bash +export JAVA_HOME=$(/usr/libexec/java_home -v 17) +export PATH=$JAVA_HOME/bin:$PATH +``` + +### Install on Windows +1. Download Zulu JDK 17 MSI installer from [Azul Downloads](https://www.azul.com/downloads/). +2. Run the installer and ensure "Add to PATH" and "Set JAVA_HOME" options are checked. + +--- + +## 3. Setting up Android SDK & Android Studio + +1. Download and install **Android Studio** (Jellyfish 2023.3.1 or newer) from [developer.android.com](https://developer.android.com/studio). +2. Open Android Studio and complete the Setup Wizard to install the default SDK components. +3. Open **SDK Manager** (Tools > SDK Manager): + - Under **SDK Platforms**, check **Android 15 (VanillaIceCream / API Level 35)**. + - Under **SDK Tools**, ensure the following are installed: + - Android SDK Build-Tools (matching API 35) + - Android SDK Command-line Tools (latest) + - Android Emulator +4. Set up environment variables: + - **macOS:** Add to `~/.zshrc`: + ```bash + export ANDROID_HOME=$HOME/Library/Android/sdk + export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools + ``` + - **Windows:** Add user environment variable `ANDROID_HOME` pointing to `C:\Users\YOUR_USERNAME\AppData\Local\Android\Sdk`. Add `%ANDROID_HOME%\platform-tools` and `%ANDROID_HOME%\emulator` to your system `Path`. + +--- + +## 4. Setting up iOS Tools (macOS Only) + +To build the native SwiftUI shell located in `iosApp/`, you need Xcode and XcodeGen. + +1. Install **Xcode** (15.0 or newer) from the Mac App Store. +2. Open Xcode to accept the license agreement and install required platform tools. +3. Install **XcodeGen** via Homebrew: + ```bash + brew install xcodegen + ``` +4. Verify XcodeGen installation: + ```bash + xcodegen --version + ``` + +--- + +## 5. Setting up Backend Tools (Supabase & Node) + +If you plan to run backend proxy functions or TV pairing authentication locally, set up the Supabase tools. + +1. Install **Node.js** (v18 or newer). +2. Install **Supabase CLI** via Homebrew or NPM: + - **macOS:** + ```bash + brew install supabase/tap/supabase + ``` + - **Windows/Linux (NPM):** + ```bash + npm install -g supabase + ``` +3. Verify installation: + ```bash + supabase --version + ``` + +--- + +## 6. Verification Checklist + +To confirm your system is fully configured, check that the following commands succeed: + +```bash +java -version # Should output OpenJDK runtime version "17.x.x" +adb --version # Should show Android Debug Bridge version +xcodegen --version # Should show version 2.40.0+ (iOS developers only) +node -v # Should show version 18+ +``` + +Once confirmed, follow the [docs/development.md](./development.md) guide to compile and run the application. + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist (this document). +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide. +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..8da49c55 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,94 @@ +# Troubleshooting and FAQ Guide + +This document lists common issues encountered during development, installation, or runtime, and provides troubleshooting steps to resolve them. + +--- + +## 1. Local Build and Compilation Failures + +### A. Java Runtime Error +- **Symptom:** `The operation couldn’t be completed. Unable to locate a Java Runtime.` +- **Reason:** No JDK is installed or configured on the terminal path. +- **Solution:** Install JDK 17 (Zulu or Temurin) and set `JAVA_HOME`. + - **macOS:** + ```bash + export JAVA_HOME=$(/usr/libexec/java_home -v 17) + export PATH=$JAVA_HOME/bin:$PATH + ``` + - Confirm settings: `java -version`. + +### B. Missing or Blank `secrets.properties` +- **Symptom:** Gradle build fails with missing parameter errors or Supabase URL verification checks fail on staging compile. +- **Reason:** Release and staging builds run a verification check task `validateReleaseSupabaseSecrets` which enforces presence of a real `SUPABASE_URL`. +- **Solution:** Create `secrets.properties` and add your endpoint configs: + ```properties + SUPABASE_URL=https://your-project.supabase.co + SUPABASE_ANON_KEY=your-actual-anon-key + ``` + +--- + +## 2. Emulator and Device Connectivity Issues + +### A. TV Device Not Showing in `adb devices` +- **Symptom:** Device list is empty or TV returns "Connection refused" when running `adb connect`. +- **Reason:** ADB debugging is disabled on the TV, or the device is on a different subnet. +- **Solution:** + 1. Confirm ADB Debugging is checked under **Settings > Device Preferences > Developer Options**. + 2. Ping the TV IP to verify network routing: `ping `. + 3. Reset the ADB server: + ```bash + adb kill-server + adb start-server + adb connect :5555 + ``` + +### B. Play Store APK Fails to Install over Staging Build +- **Symptom:** `INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package signatures do not match.` +- **Reason:** Play Store release builds use separate signing certificates compared to staging/debug builds (which utilize default debug keystores). +- **Solution:** You must uninstall the existing build before installing a different signature build: + ```bash + adb uninstall com.arvio.tv + ``` + +--- + +## 3. Runtime & Network Troubleshooting + +### A. Cloud Sync Fails with "Invalid JWT" +- **Symptom:** Logs show `GotrueException: Invalid token signature` on app startup when sync runs. +- **Reason:** The client `secrets.properties` contains `SUPABASE_ANON_KEY` credentials that do not match the target `SUPABASE_URL`. +- **Solution:** Double check your credentials in the Supabase console dashboard under **Project Settings > API**. + +### B. Live TV Page Shows "Timeout / Loader Loop" +- **Symptom:** Selecting a channel results in an infinite loader circle or a timeout error toast. +- **Reason:** The IPTV provider's stream endpoint is offline, or OkHttp's read timeout is too low for a slow IPTV stream. +- **Solution:** + - Verify that the stream URL plays correctly in a desktop media player (e.g. VLC). + - Check the logcat for `java.net.SocketTimeoutException`. ARVIO configures connection reuse, but network drops may require a playlist refresh in settings. + +--- + +## 4. FAQ (Frequently Asked Questions) + +#### Q: How do I enable the self-updater feature? +Configure the product flavor to `sideload` during compilation. The `play` flavor deliberately disables update check tasks to meet Google Play Store distribution policies. + +#### Q: Can I run this app on standard Android phones? +Yes. ARVIO is a universal responsive application. If run on a touch device, the bottom navigation bar (`AppBottomBar.kt`) replaces the TV sidebar navigation (`Sidebar.kt`) automatically. + +--- + +## 📖 Documentation Navigation + +- [README.md](../README.md) - Main repository overview. +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Guidelines for contributing code. +- [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) - Behavior and community guidelines. +- [docs/architecture.md](./architecture.md) - System architecture and dependency dataflows. +- [docs/setup.md](./setup.md) - Environment installation checklist. +- [docs/development.md](./development.md) - Development commands and workflows. +- [docs/configuration.md](./configuration.md) - App parameters and credentials reference. +- [docs/api.md](./api.md) - Edge Function API proxies documentation. +- [docs/deployment.md](./deployment.md) - CI/CD pipeline automation and TestFlight uploads. +- [docs/troubleshooting.md](./troubleshooting.md) - Common problems and resolution guide (this document). +- [docs/ios-testflight.md](./ios-testflight.md) - iOS App Store/TestFlight packaging instructions.