diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c3a30852 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# The legacy Flutter app and its vendored packages stay in the tree until the +# native app reaches parity; keep them out of GitHub's language statistics so +# the repository is shown as the native Kotlin app it now is. +lib/** linguist-vendored +packages/** linguist-vendored +android/** linguist-vendored +ios/** linguist-vendored +linux/** linguist-vendored +macos/** linguist-vendored +windows/** linguist-vendored +test/** linguist-vendored diff --git a/.github/ISSUE_TEMPLATE/bug_playback.yml b/.github/ISSUE_TEMPLATE/bug_playback.yml index 0c137238..bf3bc377 100644 --- a/.github/ISSUE_TEMPLATE/bug_playback.yml +++ b/.github/ISSUE_TEMPLATE/bug_playback.yml @@ -1,7 +1,7 @@ name: "Bug: Playback" description: Report a video/audio playback issue title: "[Playback] " -labels: ["Bug", "Playback"] +labels: ["bug"] body: - type: markdown attributes: @@ -11,48 +11,48 @@ body: - type: input id: app_version attributes: - label: Moonfin version - description: The app version / release tag (e.g., 1.0.0) - placeholder: 1.0.0 + label: Firefin version + description: Shown in Settings → About Firefin, or the release tag (e.g. 0.2.5-firefin) + placeholder: 0.2.5-firefin validations: required: true - type: dropdown id: platform attributes: - label: Platform + label: Device options: - - Android - - iOS - - macOS - - Windows - - Linux + - Fire TV Stick 2nd Generation (AFTT) + - Fire TV Stick Basic Edition (LY73PR) + - Fire TV Stick 1st Generation + - Other Fire TV device + - Other Android TV / Android 5+ device validations: required: true - type: input id: platform_version attributes: - label: OS version - description: "e.g. Android 14, iOS 17.4, macOS 15.1, Windows 11 23H2, Ubuntu 24.04" - placeholder: e.g. Android 14 + label: Fire OS / Android version + description: "Settings → Device → About, e.g. Fire OS 5.2.9.5 (Android 5.1)" + placeholder: Fire OS 5.2.9.5 validations: required: true - type: input id: device_model attributes: - label: Device model - description: "e.g. Pixel 8, iPhone 15, MacBook Air M2, etc." + label: Device model code + description: "Settings → Device → About, e.g. AFTT" validations: required: false - type: input id: server_version attributes: - label: Server type and version - description: "e.g. Jellyfin 10.10.0 or Emby 4.8.11.0" - placeholder: Jellyfin 10.x.x / Emby 4.x.x + label: Jellyfin server version + description: "Dashboard → About, e.g. Jellyfin 10.10.7" + placeholder: Jellyfin 10.x.x validations: required: true @@ -63,26 +63,22 @@ body: options: - Movie - Episode - - Live TV - - Music - - Audiobook - - Other + - Other video validations: required: true - type: textarea id: media_info attributes: - label: Media details (as much as you know, check the info button at the bottom right of the player) + label: Media details (as much as you know, e.g. from the media info in the Jellyfin web interface) description: Container, resolution, HDR type, video codec/profile, audio codec/channels, subtitle type, bitrate placeholder: | Container: MKV - Resolution: 3840x2160 - HDR: HDR10 - Video: HEVC Main10@L5.1 - Audio: EAC3 5.1 - Subs: SRT (enabled) - Bitrate: ~25 Mbps + Resolution: 1920x1080 + Video: H.264 High@L4.1 + Audio: AC3 5.1 + Subs: SRT (external) + Bitrate: ~8 Mbps validations: required: true @@ -93,7 +89,6 @@ body: options: - Direct Play - Transcode - - Downloaded / Offline - Not sure validations: required: true diff --git a/.github/ISSUE_TEMPLATE/bug_seerr.yml b/.github/ISSUE_TEMPLATE/bug_seerr.yml index 558c4731..814e4eff 100644 --- a/.github/ISSUE_TEMPLATE/bug_seerr.yml +++ b/.github/ISSUE_TEMPLATE/bug_seerr.yml @@ -1,63 +1,60 @@ name: "Bug: Seerr" description: Report a Seerr integration issue title: "[Seerr] " -labels: ["Bug", "Seer"] +labels: ["bug"] body: - type: input id: app_version attributes: - label: Moonfin version - description: The app version / release tag (e.g., 1.0.0) - placeholder: 1.0.0 + label: Firefin version + description: Shown in Settings → About Firefin, or the release tag (e.g. 0.2.5-firefin) + placeholder: 0.2.5-firefin validations: required: true - type: dropdown id: platform attributes: - label: Platform + label: Device options: - - Android - - iOS - - macOS - - Windows - - Linux + - Fire TV Stick 2nd Generation (AFTT) + - Fire TV Stick Basic Edition (LY73PR) + - Fire TV Stick 1st Generation + - Other Fire TV device + - Other Android TV / Android 5+ device validations: required: true - type: input id: platform_version attributes: - label: OS version - description: "e.g. Android 14, iOS 17.4, macOS 15.1, Windows 11 23H2, Ubuntu 24.04" - placeholder: e.g. Android 14 + label: Fire OS / Android version + description: "Settings → Device → About, e.g. Fire OS 5.2.9.5 (Android 5.1)" + placeholder: Fire OS 5.2.9.5 validations: required: true - type: input id: device_model attributes: - label: Device model - description: "e.g. Pixel 8, iPhone 15, MacBook Air M2, etc." + label: Device model code + description: "Settings → Device → About, e.g. AFTT" validations: required: false - - type: dropdown + - type: input id: connection_type attributes: - label: How are you connecting? - options: - - Local network (http://IP:5055) - - Domain + reverse proxy - - Remote access (port forward) - - Not sure + label: Moonbase and Jellyfin versions + description: Firefin reaches Seerr through the Moonbase plugin on the Jellyfin server. + placeholder: Moonbase 2.x / Jellyfin 10.x.x validations: required: true - type: dropdown id: https attributes: - label: Using HTTPS? + label: Is the Jellyfin address HTTPS? options: - "Yes" - "No" diff --git a/.github/ISSUE_TEMPLATE/bug_ui.yml b/.github/ISSUE_TEMPLATE/bug_ui.yml index 21770cf9..f12e6024 100644 --- a/.github/ISSUE_TEMPLATE/bug_ui.yml +++ b/.github/ISSUE_TEMPLATE/bug_ui.yml @@ -1,44 +1,44 @@ name: "Bug: UI" description: Report a UI/UX issue (navigation, screens, layout, etc.) title: "[UI] " -labels: ["Bug", "UI"] +labels: ["bug"] body: - type: input id: app_version attributes: - label: Moonfin version - description: The app version / release tag (e.g., 1.0.0) - placeholder: 1.0.0 + label: Firefin version + description: Shown in Settings → About Firefin, or the release tag (e.g. 0.2.5-firefin) + placeholder: 0.2.5-firefin validations: required: true - type: dropdown id: platform attributes: - label: Platform + label: Device options: - - Android - - iOS - - macOS - - Windows - - Linux + - Fire TV Stick 2nd Generation (AFTT) + - Fire TV Stick Basic Edition (LY73PR) + - Fire TV Stick 1st Generation + - Other Fire TV device + - Other Android TV / Android 5+ device validations: required: true - type: input id: platform_version attributes: - label: OS version - description: "e.g. Android 14, iOS 17.4, macOS 15.1, Windows 11 23H2, Ubuntu 24.04" - placeholder: e.g. Android 14 + label: Fire OS / Android version + description: "Settings → Device → About, e.g. Fire OS 5.2.9.5 (Android 5.1)" + placeholder: Fire OS 5.2.9.5 validations: required: true - type: input id: device_model attributes: - label: Device model - description: "e.g. Pixel 8, iPhone 15, MacBook Air M2, etc." + label: Device model code + description: "Settings → Device → About, e.g. AFTT" validations: required: true @@ -54,8 +54,7 @@ body: - Search - Library - Player UI (controls/OSD) - - Downloads - - Admin dashboard + - Media requests (Seerr) - Other validations: required: true @@ -65,9 +64,9 @@ body: attributes: label: Input method options: - - Touch - - Mouse / trackpad - - Keyboard + - Fire TV remote + - Other remote / game controller + - Keyboard or mouse - Other / not sure validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 6edf520a..2ec1d96f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Questions / Discussion - url: https://github.com/Moonfin-Client/Mobile-Desktop/discussions - about: Please ask usage questions here instead of opening an issue. + - name: Installation and sign-in help + url: https://github.com/ZepiGit/Firefin-Player-Android5.1-API22#installation + about: Sideloading, server address format, certificates and common sign-in messages. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d4546b79..9820eeba 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,7 +1,7 @@ name: Feature request description: Suggest an improvement or new feature title: "[Feature] " -labels: ["Feature Request"] +labels: ["enhancement"] body: - type: textarea id: problem @@ -16,27 +16,11 @@ body: id: proposal attributes: label: Proposed solution - description: How should Moonfin handle this? + description: How should Firefin handle this? placeholder: It would be great if... validations: required: true - - type: dropdown - id: platform - attributes: - label: Platform - description: Which platform(s) does this apply to? - multiple: true - options: - - Android - - iOS - - macOS - - Windows - - Linux - - All / Not platform-specific - validations: - required: true - - type: textarea id: alternatives attributes: diff --git a/.github/ISSUE_TEMPLATE/vent.yml b/.github/ISSUE_TEMPLATE/vent.yml index 4842303d..a1e18333 100644 --- a/.github/ISSUE_TEMPLATE/vent.yml +++ b/.github/ISSUE_TEMPLATE/vent.yml @@ -1,7 +1,7 @@ name: "Vent / Complaint" description: Just need to get something off your chest title: "[Vent] " -labels: ["Venting"] +labels: [] body: - type: markdown attributes: @@ -34,7 +34,7 @@ body: attributes: label: Who's at fault? options: - - Moonfin + - Firefin - My server - My network - My ISP diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0d2dd863..56881005 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,19 +27,18 @@ List the key changes included in this PR. - - -## Platform -- [ ] Android -- [ ] iOS -- [ ] macOS -- [ ] Windows -- [ ] Linux -- [ ] All / Shared code +## Area +- [ ] Native app (`app/`) +- [ ] Legacy Flutter app (`lib/`, `packages/`, `android/`) +- [ ] Documentation +- [ ] Build / CI ## Testing Describe how this change was tested. -- [ ] Tested on emulator / simulator -- [ ] Tested on physical device +- [ ] Unit tests (`./gradlew :app:testDebugUnitTest`) +- [ ] Tested on the API 22 emulator +- [ ] Tested on a physical Fire TV device - [ ] Manual testing completed - [ ] Not tested (explain why): diff --git a/BUILDING.md b/BUILDING.md index 1e884ce3..705f4807 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -3,12 +3,12 @@ This document covers the native Kotlin app (`app/`). The legacy Flutter build is kept at the end as history; it is validation-only and is never published. -Build status for implementation snapshot `c9da933aa2a331c760325b97e777b51be7d4980f`: local verification passed -60 unit tests, strict lint (0 errors; current reports contain 118 warnings) and an R8 release build, and -the debug APK installed and launched on an API 22 stock x86 emulator. Signed -APKs come only from the protected release workflow (release `0.2.3-firefin` is -published); there is no AFTT hardware result. The commit carrying these fixes -is documented per commit; CI runs are listed in docs/FIREFIN_MIGRATION.md. +Build status for `0.2.5-firefin`: local verification passed 83 JVM unit tests, +strict lint (0 errors, 74 warnings), an R8 release build and the APK verifier, +and two instrumented tests on an API 22 stock x86 emulator. Signed APKs come +only from the protected release workflow; the newest published release is +`0.2.4-firefin`. There is no AFTT hardware result. CI results are recorded per +commit in GitHub Actions. ## Toolchain (native) @@ -20,7 +20,7 @@ is documented per commit; CI runs are listed in docs/FIREFIN_MIGRATION.md. | Android SDK | `platforms;android-35`, `build-tools;35.0.0` | | App SDK levels | minSdk 21, targetSdk 34, compileSdk 35 | | Application ID | `zepigit.firefin.app` | -| Version | `versionName=0.2.4-firefin`, `versionCode=3001004` (from `version.properties`) | +| Version | `versionName=0.2.5-firefin`, `versionCode=3001005` (from `version.properties`) | `version.properties` is the single source of version identity. Keep the native version code above the legacy `3000028`. @@ -88,13 +88,17 @@ adb shell dumpsys activity activities | grep -i resumed # expect LoginActivity adb logcat -d | grep -E "FATAL|AndroidRuntime" # expect no output ``` -The earlier recorded smoke was only an install/launch/package-identity check -and is historical. The named implementation snapshot has a passing -`:app:connectedDebugAndroidTest` login-focus test and a separate API-22 - evidence record in `docs/FIREFIN_MIGRATION.md`: authorized login, Home/library/detail/search/ -settings, Seerr discovery/search/detail/season confirmation, TMDB artwork, -baseline playback, resume, track dialog and cleanup. This remains x86 emulator -evidence, not AFTT hardware evidence. +Instrumented tests run on the same emulator: + +```bash +./gradlew :app:connectedDebugAndroidTest +``` + +They check the login screen and run the app's client against a local fixture +server on the device: sign-in over HTTP, setting and clearing a favorite, +paging through 125 episodes, sign-out and the prefilled sign-in screen. The +manual API 22 records in `docs/FIREFIN_MIGRATION.md` cover the remaining flows. +All of this is x86 emulator evidence, not AFTT hardware evidence. ## CI (`Native Android CI`, `.github/workflows/android.yml`) @@ -107,18 +111,19 @@ upload (`if-no-files-found: error`). Artifacts: `firefin-candidate-` (both APKs and verification JSON) and `firefin-reports-` (test and lint reports). The candidate APKs are -validation artifacts, not releases. Native CI results are recorded per commit in GitHub Actions. The local results -above describe implementation snapshot `c9da933aa2a331c760325b97e777b51be7d4980f`, not a signed release. +validation artifacts, not releases. Native CI results are recorded per commit +in GitHub Actions; the local results above are not CI evidence. ## Protected release (`Firefin signed release`, `.github/workflows/release.yml`) -Manual (`workflow_dispatch`) only. Release `0.2.3-firefin` was produced with it (run 37055114680). +Manual (`workflow_dispatch`) only. Releases `0.2.3-firefin` (run 37055114680) +and `0.2.4-firefin` (run 37067878548) were produced with it. Inputs: | Input | Requirement | |---|---| -| `tag` | Existing tag `firefin-v`, e.g. `firefin-v0.2.3-firefin`; must equal `firefin-v` plus `versionName` in `version.properties` | +| `tag` | Existing tag `firefin-v`, e.g. `firefin-v0.2.5-firefin`; must equal `firefin-v` plus `versionName` in `version.properties` | | `commit` | Full 40-character reviewed commit SHA; must be the tag's commit | Gates, all enforced by the workflow: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b160841..7e1206e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,89 @@ preserved unchanged as history. ## Native Firefin +### 0.2.5-firefin (versionCode 3001005) + +Not yet published as a signed release. + +Sign-in and connection: + +- A wrong user name or password is reported as such. Sign-in errors are now + classified by type instead of message text: rejected credentials, server error + with HTTP status, untrusted certificate, unknown host, unreachable server and + invalid address, each with an English and German message. +- An address typed without a scheme is completed to `https://`. If that fails + with a TLS error, the message suggests entering the address with `http://` + (Jellyfin's default port 8096 serves plain HTTP); there is no silent fallback. +- An `http://` address asks for confirmation on every sign-in. Cleartext is now + also permitted on Android 9 and newer, where it was blocked by the platform + default before. +- Jellyfin connections (REST, media and playback snapshots) trust ISRG Root X1 + (Let's Encrypt) in addition to the platform certificate store, which on + Android 5.1 does not contain it. Chain and hostname validation are unchanged. +- Signing out revokes the access token on the server (best effort) and keeps the + server address and user name to prefill the next sign-in. + +Playback: + +- A media read that receives no data for 30 seconds now fails into Media3's + retry and error handling instead of buffering indefinitely; long streams are + not limited in total duration. +- The natural end of playback closes the player and reports the stop once. A + fatal playback error ends the session and offers *Retry* (new negotiation at + the same position, new session) or *Back*, with a localized reason instead of + the Media3 error code. +- Subtitles are chosen from the server's list during direct play too, so + external subtitle files can be switched on, changed and switched off. The + change renegotiates the stream at the current position and keeps the + play/pause state; audio tracks of a direct-played file still switch locally. +- The screen is kept on only while playback is wanted, so the Fire TV + screensaver can start on a paused player. Holding a seek key on a non-HLS + transcode no longer restarts the transcode on every key repeat. + +Browsing: + +- Removing a favorite or watched mark sends `DELETE` to the item resource. + Before, the app sent `POST …/Delete`, which is not the route Jellyfin's API + defines for removing a mark (that is `DELETE` on the same path). +- Season, collection and folder contents load in pages of 60 as the focus nears + the end, instead of stopping after 60 entries. A failed page keeps the loaded + entries and is requested again on the next focus move. +- Episodes show their number (`E04` in a season, `S03E04` elsewhere). Series + offer a *Continue* button for the next episode. +- The home preview shows the description for *Recently added* items. Home rows + load concurrently. +- The library list shows only library types Firefin can play (movies, shows, + home videos, collections, mixed); search no longer lists music albums. +- Settings show their current value. Library sort names are localized. +- Seerr *My requests* loads title details with up to four concurrent requests. + +Maintenance: + +- Session-expired, server-status, oversized-response, unplayable-source and + incomplete sign-in errors are shown in the device language. +- The artwork disk cache trims least recently used files first, and an + out-of-memory error while decoding one image no longer stops the shared + decode workers. +- The unused `WAKE_LOCK` permission was removed. +- `scripts/verify-native-apk.py` uses explicit checks instead of `assert`, so + it also fails closed under `python -O`. +- Issue and pull request templates describe Firefin and Fire TV devices instead + of the Moonfin desktop and mobile platforms; GitHub's language statistics + exclude the legacy Flutter tree. + +Verification: 83 JVM unit tests; on the API 22 x86 emulator two instrumented +tests (login screen, and sign-in over HTTP, mark/unmark, paging through 125 +episodes, sign-out and prefilled sign-in against a local fixture server) and a +manual check of the sign-in error and cleartext dialog. Playback was not +exercised on the emulator for this version, and there is still no result from +physical AFTT hardware. + ### 0.2.4-firefin (versionCode 3001004) +Published on 2026-10-02 as a signed GitHub release (tag `firefin-v0.2.4-firefin`) +by the protected release workflow (run 37067878548) from commit +`503678d519950d74c4cfb6d3d93a08150716a3af`. No AFTT hardware result is recorded. + - The interface now defaults to English. The previous German texts are kept as a German translation (`values-de`) and are used on devices set to German. - User-visible text in the screens and layouts moved from Kotlin code into @@ -86,8 +167,8 @@ request submission are intentionally not claimed. See Earlier notes claimed clean lint, "all review findings fixed", protected signing, bounded disk decode, and completed API 22 validation. Those claims were not acceptance evidence and are withdrawn. The earlier figures of 18 or -21 unit tests and the 5m25s CI run describe older trees, not the current one -(60 unit tests). +21 unit tests and the 5m25s CI run describe older trees, not the current one; +each version entry above names its own test count. ## Legacy Flutter line (history) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ddefea37..e8657307 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -5,22 +5,24 @@ | Device | Fire OS / Android | Status for native Firefin | |---|---|---| | Fire TV Stick 2nd Generation (`AFTT`) | Fire OS 5 / Android 5.1 (API 22) | Primary target | -| Fire TV Stick Basic Edition (`AFTT`, `LY73PR`) | Fire OS 5 / Android 5.1 (API 22) | -| Fire TV Stick 1st Generation | Fire OS 5 / Android 5.1 | -| Newer 64-bit Fire TV devices | Fire OS 6+ | Might work I guess | +| Fire TV Stick Basic Edition (`AFTT`, `LY73PR`) | Fire OS 5 / Android 5.1 (API 22) | Primary target | +| Fire TV Stick 1st Generation | Fire OS 5 / Android 5.1 | Expected to work (ARMv7), not tested | +| Newer 64-bit Fire TV devices | Fire OS 6+ | Might work, not tested; not the intended target | -Native app: minSdk 21, targetSdk 34, `zepigit.firefin.app`, `0.2.4-firefin` -(versionCode `3001004`). Landscape only; touchscreen and Leanback are declared -optional; Leanback launcher and TV banner are present. +Native app: minSdk 21, targetSdk 34, `zepigit.firefin.app`, `0.2.5-firefin` +(versionCode `3001005`). Landscape only; touchscreen and Leanback are declared +optional; Leanback launcher and TV banner are present. Cleartext `http://` +servers work on all supported Android versions after a confirmation. ## Native verification status | Evidence | Result | |---|---| -| Tested implementation snapshot `c9da933aa2a331c760325b97e777b51be7d4980f` | 60 unit tests pass; strict debug/release lint 0 errors; Debug/Release APKs build; the CI release build is unsigned by design, signed APKs come only from the protected release workflow | -| API 22 stock x86 emulator | Login, Moonfin-like Home/Library/Detail/Search/Settings, Seerr discovery/search/detail/season confirmation, TMDB artwork, baseline 480p playback up to 1080p, resume, D-pad controller, audio dialog and cleanup exercised | +| Local build of `0.2.5-firefin` | 83 JVM unit tests pass; strict debug/release lint 0 errors (74 warnings); Debug/Release APKs build and pass the APK verifier; the CI release build is unsigned by design, signed APKs come only from the protected release workflow | +| API 22 stock x86 emulator, `0.2.5-firefin` | Two instrumented tests pass (login screen; sign-in over HTTP, favorite set/clear, paging through 125 episodes, sign-out and prefilled sign-in against a local fixture server); sign-in error and cleartext dialog checked manually. Playback was not exercised for this version | +| API 22 stock x86 emulator, snapshot `c9da933` (before 0.2.4) | Login, Moonfin-like Home/Library/Detail/Search/Settings, Seerr discovery/search/detail/season confirmation, TMDB artwork, baseline 480p playback up to 1080p, resume, D-pad controller, audio dialog and cleanup exercised | | Physical AFTT (ARMv7) | **Absent.** No hardware decoder/memory/thermal result | -| Signed release APK | Release was built, signed and verified against the pinned production certificate by the protected release workflow (run 37055114680). +| Signed release APK | `0.2.3-firefin` (run 37055114680) and `0.2.4-firefin` (run 37067878548) were built, signed and verified against the pinned production certificate by the protected release workflow | | Final commit CI | Consult the exact commit's GitHub Actions record; local results are not CI evidence | @@ -61,7 +63,9 @@ for private content and clearable in Settings. Player, controller hidden: Left/Right and Rewind/Fast Forward seek 10 s; Center/Up/Down/Menu show the controller; Play/Pause toggles playback. Controller visible: Menu hides it, Up opens the audio-track dialog, Down the subtitle -dialog, other keys navigate the controller. Back hides the visible controller first; a subsequent Back leaves the player. +dialog, other keys navigate the controller. Back hides the visible controller +first; a subsequent Back leaves the player. The player closes by itself at the +end of the media; a playback error offers Retry or Back. ## Legacy Flutter app (history only) diff --git a/FIRETV32-VERIFICATION.txt b/FIRETV32-VERIFICATION.txt index cb3bdf44..7893daca 100644 --- a/FIRETV32-VERIFICATION.txt +++ b/FIRETV32-VERIFICATION.txt @@ -10,8 +10,9 @@ Minimum Android SDK: 21 Target Android SDK: 35 Compile Android SDK: 36 Native ABI: armeabi-v7a only -APK size: (build pending — run build-firetv32.sh / flutter build apk) -APK SHA-256: (fill after release APK is produced) +APK size: not recorded in this file +APK SHA-256: not recorded in this file; the APK is attached to the + v1.1.0-firetv32-r21 release Certificate SHA-256: 3633514033016395dc07407a414bb3857a84eca682ef021c8f82c5e6abc622d0 r21 performance changes (static review): @@ -22,9 +23,11 @@ r21 performance changes (static review): - Image Dio/HttpClient maxConnectionsPerHost = 2 Static validation: -- Clean Flutter release build: pending (Flutter SDK may be absent on build host) -- FireTV32 URL / image / decode-key regression tests: run when Flutter available -- aapt manifest/package inspection: pending after APK +- Clean Flutter release build: not recorded in this file (see the legacy + Flutter CI workflow runs) +- FireTV32 URL / image / decode-key regression tests: part of `flutter test`, + results not recorded in this file +- aapt manifest/package inspection: not recorded in this file - Fire TV Leanback launcher and application banner: present in sources - Touchscreen requirement: disabled - Native libraries: armeabi-v7a only (packaging unchanged) diff --git a/README.md b/README.md index 3fe77b2c..b2e4e3de 100644 --- a/README.md +++ b/README.md @@ -30,24 +30,24 @@ Firefin is a 32bit native Android TV application for watching, discovering and b - Media3 playback with audio and subtitle selection, resume support and D-pad controls - Seerr request discovery, title details, season selection and confirmation flows -This project is not about being the best Jellyfin/Moonfin alternative. It doesn't even aim to be a good Jellyfin/Moonfin player at all. The goal is to give perfectly fine old devices that were obsolete due to no native app compatability a purpose again and recreate compatability. +This project is not about being the best Jellyfin/Moonfin alternative. It doesn't even aim to be a good Jellyfin/Moonfin player at all. The goal is to give perfectly fine old devices, which became obsolete because no native app supports them anymore, a purpose again and restore compatibility. ## Screenshots
- Firefin home screen with icon navigation and Continue Watching row + Firefin home screen in the German UI with icon navigation and Continue Watching row

Home
Icon navigation and continue watching.

- South Park season three and episode list in the German Firefin UI + Season detail with episode list in the German Firefin UI

Series
Overview, watched state and season browsing.

- South Park episode details in Firefin with playback actions + Episode details in the German Firefin UI with playback actions

Episode details
Playback, favourites and watched-state actions.

@@ -57,6 +57,8 @@ This project is not about being the best Jellyfin/Moonfin alternative. It doesn'
+The screenshots show the German translation; the interface follows the device language and defaults to English. + ## Works with | Service | Compatibility | @@ -72,24 +74,52 @@ Firefin does not require a second media server. It connects to the Jellyfin serv |---|---| | **Fire TV Stick 2nd Generation (AFTT)** | Primary target; Fire OS 5 / Android API 22 / ARMv7. | | **Fire TV Stick Basic Edition (LY73PR)** | Primary target; Fire OS 5 / Android API 22 / ARMv7. | -| **Fire TV Stick 1st Generation** | -| **Newer 64-bit Fire TV devices** | Might work, but for gods sake, use something else please. | +| **Fire TV Stick 1st Generation** | Expected to work (Fire OS 5 / ARMv7), not tested. | +| **Newer 64-bit Fire TV devices** | Might work, but for God's sake, use something else please. | + +The app is intentionally tuned for the resource limits of the older Fire TV sticks. No flashy animations, no bling bling, but it gets the job done. Newer hardware may run it, but compatibility should not be assumed without testing. + +## Installation + +Signed Firefin APKs are published on the [releases page](https://github.com/ZepiGit/Firefin-Player-Android5.1-API22/releases); Firefin releases are tagged `firefin-v`. Firefin is not in the Amazon Appstore, so it is installed by sideloading. -The app is intentionally tuned for the resource limits of the older Fire TV sticks. No flashy anymations, no bling bling, but gets the job done. Newer hardware may run it, but compatibility should not be assumed without testing. +1. **Allow sideloading.** On the Fire TV open *Settings → Device* (on newer models *My Fire TV*) *→ Developer options* and turn on *Apps from Unknown Sources*. For the ADB route also turn on *ADB debugging*. +2. **Install the APK**, either way works: + - **Downloader app:** install *Downloader* from the Amazon Appstore, open the releases page above in it and select `Firefin_.apk` of the newest release. + - **ADB from a computer** on the same network: -## Getting started + ```bash + adb connect + adb install -r Firefin_.apk + ``` +3. **Optional: check the download.** Compare the APK's SHA-256 with `SHA256SUMS.txt` from the same release (`sha256sum` on Linux/macOS, `Get-FileHash` in PowerShell). +4. **Sign in** with your Jellyfin address, user name and password: + - Home network without HTTPS: `http://192.168.1.10:8096` (Firefin asks you to confirm the unencrypted connection). + - HTTPS: `https://jellyfin.example.com`. Certificates from public authorities, including Let's Encrypt, are supported; self-signed certificates are not. + +**Updates:** install the new APK over the old one; sign-in and settings stay. The old *Moonfin FireTV32* app (`org.moonfin.firetv32`) is a separate app: it can stay installed next to Firefin, and its sign-in and settings are not taken over. + +### Troubleshooting + +| Message or symptom | What to check | +|---|---| +| "The server did not accept a secure HTTPS connection" | The address was entered without `http://` and the server only speaks HTTP. Enter it with `http://`. | +| "This device does not trust the server certificate" | The certificate must be valid, match the address and come from a public authority. Fire OS 5 has an old certificate store; Firefin adds Let's Encrypt itself. | +| "The server cannot be reached" | Address, port (Jellyfin default: 8096) and that the Fire TV is on the same network. | +| Media requests are unavailable | Seerr is reached through the Moonbase plugin on the Jellyfin server; without Moonbase, requests stay hidden. | -Signed Firefin-APKs are published on the [releases page](https://github.com/ZepiGit/Firefin/releases); Firefin releases are tagged `firefin-v`. Firefin 32bit is still in development. To build it yourself, start with [BUILDING.md](BUILDING.md) and the technical overview in [docs/FIREFIN_TECHNICAL_OVERVIEW.md](docs/FIREFIN_TECHNICAL_OVERVIEW.md). +To build Firefin yourself, start with [BUILDING.md](BUILDING.md) and the technical overview in [docs/FIREFIN_TECHNICAL_OVERVIEW.md](docs/FIREFIN_TECHNICAL_OVERVIEW.md). ## Project status -The native TV experience is actively being completed. Firefin does not claim support for Live TV/DVR, offline downloads, music or book readers, DLNA, administration or every modern Jellyfin client feature, but if you got a old fireTV or AndroidTV Device laying around, it might be just for you. +Firefin 32bit is still in development and the native TV experience is actively being completed. It has been tested on an Android 5.1 (API 22) emulator; a run on physical Fire TV Stick hardware is still outstanding. Sending media requests to a live Seerr server has not been verified yet. Firefin does not claim support for Live TV/DVR, offline downloads, music or book readers, DLNA, administration or every modern Jellyfin client feature, but if you've got an old Fire TV or Android TV device lying around, it might be just for you. For compatibility boundaries, security notes and verification evidence, see: - [Compatibility](COMPATIBILITY.md) - [Security](SECURITY.md) - [Technical overview](docs/FIREFIN_TECHNICAL_OVERVIEW.md) +- [Changelog](CHANGELOG.md) - [Third-party notices](THIRD_PARTY_NOTICES.md) ## License diff --git a/SECURITY.md b/SECURITY.md index b2db317e..5f392505 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,24 +13,28 @@ unsanitized logs in public issues. These describe the code in `ServerTransport` and `Urls`. They are not a completed security audit; open items are listed below. -- **Jellyfin TLS trust.** Jellyfin REST/media clients use platform trust and - hostname validation. The native app has no trust-all `TrustManager`, - `HostnameVerifier` bypass or global accept-all switch. Only the public TMDB - artwork client adds the bundled ISRG Root X1 anchor through a scoped - `HandshakeCertificates` client; this is not pinning or a validation bypass. - Conscrypt 2.5.2 is installed for API-22 TLS compatibility. -- **Fire OS 5 caveat.** Fire OS 5 ships a frozen certificate store. Some - currently valid public chains and many private Jellyfin certificates may be - rejected. Firefin does not work around this by disabling validation; use a - server with a certificate chain the device trusts, or a trusted local path. - Real AFTT trust behavior has not been tested. -- **Server address.** A bare host defaults to `https://`. Addresses with - embedded credentials, a query string, a fragment or a non-HTTP(S) scheme are - rejected. An explicit `http://` address typed by the user is accepted; there - is no separate consent prompt or warning yet (open item), and the manifest - sets no cleartext policy, so platform defaults apply (API 23+ blocks cleartext - for this target SDK; API 22 allows it). Use HTTP only on a trusted local - network. +- **TLS trust.** Jellyfin REST, media and playback-snapshot clients and the + public TMDB artwork client use the platform trust store plus the bundled + ISRG Root X1 anchor (Let's Encrypt), added through one scoped + `HandshakeCertificates` instance. Android ships ISRG Root X1 only from 7.1.1 + on. Chain and hostname validation stay with the platform; there is no + trust-all `TrustManager`, `HostnameVerifier` bypass, pinning or global + accept-all switch. JVM tests cover an added anchor, a wrong hostname and an + unknown self-signed certificate. Conscrypt 2.5.2 is installed for API-22 TLS + compatibility. +- **Fire OS 5 caveat.** Fire OS 5 ships a frozen certificate store. Public + chains other than Let's Encrypt and private or self-signed Jellyfin + certificates may still be rejected. Firefin does not work around this by + disabling validation; use a server with a publicly trusted chain, or a trusted + local path. Real AFTT trust behavior has not been tested. +- **Server address.** A bare host defaults to `https://`; if that fails with a + TLS error, the sign-in message suggests `http://`, but the app never switches + scheme by itself. Addresses with embedded credentials, a query string, a + fragment or a non-HTTP(S) scheme are rejected. An explicit `http://` address + requires confirming an unencrypted-connection warning on every sign-in. The + manifest permits cleartext (`usesCleartextTraffic`), so this works the same on + Android 9 and newer, where the platform default for this target SDK would + block it. Use HTTP only on a trusted local network. - **Origin binding.** Every request must match the configured scheme, host, port and path prefix (complete path segments, not string prefixes) and carry no URL userinfo; otherwise it is refused before sending. @@ -48,10 +52,13 @@ completed security audit; open items are listed below. wired into playback; media URLs returned by the server are used as-is if they are same-origin. - **Limits.** REST responses larger than 8 MiB are rejected; REST calls time - out after 25 s. Request cancellation cancels the underlying OkHttp call. + out after 25 s. Media calls have no total limit, but a read that receives no + data for 30 s fails. Request cancellation cancels the underlying OkHttp call. - **Storage.** The session (server URL, user, access token, device ID) is kept in app-private SharedPreferences, unencrypted, with `allowBackup="false"`. - Logout clears server, user and token. + Sign-out asks the server to revoke the token (`POST /Sessions/Logout`, best + effort) and clears the token and user ID locally; the server address and user + name are kept to prefill the next sign-in. - **Version header.** The client identifies itself with `BuildConfig.VERSION_NAME`, the version from `version.properties`, matching the APK version. @@ -64,7 +71,8 @@ only to release maintainers. The release workflow reads the key from the `FIREFIN_CERTIFICATE_SHA256` variable (see [BUILDING.md](BUILDING.md)); the key should be exposed only to the protected `firefin-release` job. The secrets and the variable are currently stored at repository level, not inside the -`firefin-release` environment, and were used to publish `0.2.3-firefin`; the +`firefin-release` environment, and were used to publish `0.2.3-firefin` and +`0.2.4-firefin`; the reviewer and branch rules of the environment gate the release job, but moving the values into the environment is an open hardening step. No production key or fingerprint is stored in this repository, and there is no debug-key release @@ -89,17 +97,21 @@ The legacy `org.moonfin.firetv32` app uses a compatibility transport that the Dart network code) to cope with the frozen Fire OS 5 certificate store. That weakens protection against an active network attacker. It is not part of the native app. If you still run the legacy app, use it only on a trusted local -network or VPN. Legacy builds are validation-only in CI and are not published. +network or VPN. CI builds of the legacy app are validation-only; the earlier +community releases `v1.1.0-firetv32-r20` and `v1.1.0-firetv32-r21` remain on the +releases page as history and carry this limitation. ## Open security items -- Explicit user consent flow for cleartext HTTP. -- Dedicated tests for media-client redirects and API 22 certificate behavior. +- Certificate behavior on physical Fire OS 5 hardware (AFTT). +- Repository-level signing secrets: move them into the `firefin-release` + environment. - Dependency lock/verification metadata (versions are pinned, hashes are not). - Wiring token/query stripping for server-supplied media URLs, if required. ## Reporting -Open a private security report in the repository hosting platform where -available. Include affected version, reproduction steps, impact and a minimal -sanitized log. +Use GitHub's private vulnerability reporting for this repository when it is +enabled. Until then, open an issue that only asks for a private contact and +contains no exploit details. Include affected version, reproduction steps, +impact and a minimal sanitized log in the private report. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1bf556e9..1a49ee7f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,7 +47,8 @@ standard library and Okio): Test only (not packaged): `junit` 4.13.2, `okhttp3:mockwebserver` 4.12.0, `kotlinx-coroutines-test` 1.8.1, `org.json:json` 20231013, AndroidX Test -`core` 1.5.0, `ext:junit` 1.1.5, `runner` 1.5.2 and `rules` 1.5.0. +`core` 1.5.0, `ext:junit` 1.1.5, `runner` 1.5.2 and `rules` 1.5.0; the +instrumented tests also use `okhttp3:mockwebserver` 4.12.0. The release APK packages Conscrypt's `libconscrypt_jni.so` for ARMv7; debug also packages x86 for the API-22 emulator. @@ -62,9 +63,9 @@ The legacy Flutter build bundled the Flutter engine and media_kit/mpv libraries; those must not appear in the native APK. Bundled public certificate: `app/src/main/res/raw/isrgrootx1.pem` is the -ISRG Root X1 certificate from Let's Encrypt, used only as an additional trust -anchor by the scoped TMDB artwork client on legacy API 22. It is not a -Jellyfin trust bypass or certificate pin. The certificate SHA-256 is recorded +ISRG Root X1 certificate from Let's Encrypt, added next to the platform trust +anchors for the Jellyfin and TMDB clients because Android 5.1 does not ship it. +It is neither a trust bypass nor a certificate pin. The certificate SHA-256 is recorded in the release audit artifact, not in a secret-bearing file. Toolbar vector icons in `app/src/main/res/drawable/ic_*.xml` are based on diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21ee7c93..08e2f501 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,4 +81,5 @@ dependencies { androidTestImplementation("androidx.test:runner:1.5.2") androidTestImplementation("androidx.test:rules:1.5.0") androidTestImplementation("junit:junit:4.13.2") + androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } diff --git a/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt b/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt index 1c7a1e94..cbef36c3 100644 --- a/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt +++ b/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt @@ -1,18 +1,35 @@ package zepigit.firefin.app -import android.content.Intent +import android.content.Context import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import zepigit.firefin.app.data.MediaItem +import zepigit.firefin.app.data.PageCursor import zepigit.firefin.app.ui.LoginActivity @RunWith(AndroidJUnit4::class) class Api22FlowTest { + @Before fun signedOut() { + InstrumentationRegistry.getInstrumentation().targetContext + .getSharedPreferences("firefin_session", Context.MODE_PRIVATE).edit().clear().commit() + } + @Test fun loginScreenStartsWithoutCrashAndExposesServerField() { - androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext - .getSharedPreferences("firefin_session", android.content.Context.MODE_PRIVATE).edit().clear().commit() ActivityScenario.launch(LoginActivity::class.java).use { scenario -> scenario.onActivity { activity -> assertTrue(activity.findViewById(R.id.serverUrl).isFocusable) @@ -20,4 +37,75 @@ class Api22FlowTest { } } } + + /** + * Runs the app's own client on the API 22 runtime against a local fixture + * server: cleartext sign-in, mark/unmark, paging past the first page and + * sign-out. This covers request and state logic, not decoders or hardware. + */ + @Test fun signInMarkPageAndSignOutAgainstLocalServer() = runBlocking { + val server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val url = request.requestUrl!! + return when (url.encodedPath) { + "/Users/AuthenticateByName" -> MockResponse().setBody("""{"AccessToken":"tok","ServerId":"srv","User":{"Id":"u1","Name":"tester"}}""") + "/Users/u1/Items" -> { + val start = url.queryParameter("StartIndex")!!.toInt() + val limit = url.queryParameter("Limit")!!.toInt() + val items = JSONArray() + for (index in start until minOf(start + limit, EPISODES)) { + items.put(JSONObject().put("Id", "e$index").put("Type", "Episode").put("IndexNumber", index + 1)) + } + MockResponse().setBody(JSONObject().put("Items", items).put("TotalRecordCount", EPISODES).toString()) + } + else -> MockResponse().setBody("{}") + } + } + } + server.start() + val address = server.url("/").toString().trimEnd('/') + try { + val client = ServiceLocator.client + client.login(address, "tester", "pw") + assertTrue(ServiceLocator.session.isLoggedIn) + client.setFavorite("i1", true) + client.setFavorite("i1", false) + + val season = MediaItem.from(JSONObject().put("Id", "s1").put("Type", "Season")) + val cursor = PageCursor() + val ids = mutableListOf() + while (true) { + val start = cursor.next() ?: break + val (page, total) = client.children(season, start) + cursor.received(page.size, total) + ids += page.map { it.id } + } + assertEquals((0 until EPISODES).map { "e$it" }, ids) + + client.logout() + ServiceLocator.session.clear() + val requests = generateSequence { server.takeRequest(5, TimeUnit.SECONDS) } + .map { "${it.method} ${it.requestUrl!!.encodedPath}" }.toList() + assertTrue(requests.contains("POST /Users/u1/FavoriteItems/i1")) + assertTrue(requests.contains("DELETE /Users/u1/FavoriteItems/i1")) + assertFalse(requests.any { it.endsWith("/Delete") }) + assertTrue(requests.contains("POST /Sessions/Logout")) + } finally { + server.shutdown() + } + + assertFalse(ServiceLocator.session.isLoggedIn) + ActivityScenario.launch(LoginActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals(address, activity.findViewById(R.id.serverUrl).text.toString()) + assertEquals("tester", activity.findViewById(R.id.username).text.toString()) + assertEquals(R.id.password, activity.currentFocus?.id) + } + } + } + + private companion object { + const val EPISODES = 125 + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 254b5057..71688bd3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - + android:theme="@style/Theme.Firefin" + android:usesCleartextTraffic="true"> val url = chain.request().url diff --git a/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt b/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt index 97aa1828..59853864 100644 --- a/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt +++ b/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt @@ -2,8 +2,13 @@ package zepigit.firefin.app.data import android.os.Build import zepigit.firefin.app.BuildConfig +import zepigit.firefin.app.R +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.tls.HandshakeCertificates import org.json.JSONArray import org.json.JSONObject import zepigit.firefin.app.playback.DeviceProfile @@ -12,7 +17,7 @@ import java.io.IOException import java.net.URLEncoder /** Jellyfin REST client using one origin-bound, cancellable ServerTransport. */ -class JellyfinClient(private val session: SessionStore, private val preferences: () -> zepigit.firefin.app.preferences.EffectiveDevicePreferences = { +class JellyfinClient(private val session: SessionStore, trust: HandshakeCertificates? = null, private val preferences: () -> zepigit.firefin.app.preferences.EffectiveDevicePreferences = { zepigit.firefin.app.preferences.deriveEffective(zepigit.firefin.app.preferences.StoredPreferences(), DeviceProfile.MAX_WIDTH, DeviceProfile.MAX_HEIGHT, DeviceProfile.MAX_BITRATE) }) { private val transport = ServerTransport( @@ -20,7 +25,9 @@ class JellyfinClient(private val session: SessionStore, private val preferences: session.deviceId, Build.MODEL?.takeIf { it.isNotBlank() } ?: "Fire TV", VERSION, + trust = trust, ) + private val background = CoroutineScope(SupervisorJob() + Dispatchers.IO) val okHttp: okhttp3.OkHttpClient get() = transport.mediaHttp val baseUrl: String get() = session.serverUrl @@ -34,8 +41,8 @@ class JellyfinClient(private val session: SessionStore, private val preferences: val body = JSONObject().put("Username", username).put("Pw", password) val json = JSONObject(transport.json("Users/AuthenticateByName", "POST", body.toString())) val token = json.optString("AccessToken").takeIf { it.isNotBlank() } - ?: throw IOException("Login response did not contain an access token.") - val user = json.optJSONObject("User") ?: throw IOException("Login response did not contain a user.") + ?: throw LocalizedIOException(R.string.error_login_response, "Login response did not contain an access token.") + val user = json.optJSONObject("User") ?: throw LocalizedIOException(R.string.error_login_response, "Login response did not contain a user.") session.serverUrl = server session.accessToken = token session.userId = user.optString("Id") @@ -68,7 +75,7 @@ class JellyfinClient(private val session: SessionStore, private val preferences: } suspend fun latest(parentId: String? = null, limit: Int = 12): List = withContext(Dispatchers.IO) { - var path = "Users/${session.userId}/Items/Latest?Limit=$limit&Fields=PrimaryImageAspectRatio,ProductionYear&EnableImageTypes=Primary,Backdrop,Thumb" + var path = "Users/${session.userId}/Items/Latest?Limit=$limit&Fields=PrimaryImageAspectRatio,ProductionYear,Overview&EnableImageTypes=Primary,Backdrop,Thumb" if (!parentId.isNullOrBlank()) path += "&ParentId=${urlEncode(parentId)}" parseArray(transportText(path)) } @@ -88,9 +95,14 @@ class JellyfinClient(private val session: SessionStore, private val preferences: suspend fun item(itemId: String): MediaItem = withContext(Dispatchers.IO) { MediaItem.from(getJson("Users/${session.userId}/Items/${urlEncode(itemId)}")) } - suspend fun children(container: MediaItem): List = withContext(Dispatchers.IO) { - if (container.isSeries) parseItems(getJson("Shows/${urlEncode(container.id)}/Seasons?UserId=${urlEncode(session.userId)}")) - else items(parentId = container.id, recursive = false, sortBy = "IndexNumber", limit = 60).first + /** One page of a container's children plus the total count; a series returns all of its seasons at once. */ + suspend fun children(container: MediaItem, startIndex: Int = 0, limit: Int = CHILD_PAGE_SIZE): Pair, Int> = withContext(Dispatchers.IO) { + if (container.isSeries) { + val seasons = parseItems(getJson("Shows/${urlEncode(container.id)}/Seasons?UserId=${urlEncode(session.userId)}")) + seasons to seasons.size + } else { + items(parentId = container.id, startIndex = startIndex, recursive = false, sortBy = "IndexNumber", limit = limit) + } } suspend fun playbackInfo(itemId: String, audioIndex: Int? = null, subtitleIndex: Int? = null, pinnedTransport: ServerTransport? = null, mediaSourceId: String? = null): PlaybackSource = withContext(Dispatchers.IO) { @@ -120,7 +132,7 @@ class JellyfinClient(private val session: SessionStore, private val preferences: val sources = json.optJSONArray("MediaSources") ?: JSONArray() val source = (0 until sources.length()).mapNotNull { sources.optJSONObject(it) }.filter { mediaSourceId == null || it.optString("Id") == mediaSourceId }.firstOrNull { source -> source.optBoolean("SupportsDirectPlay", false) || source.optBoolean("SupportsDirectStream", false) || source.optBoolean("SupportsTranscoding", false) || source.optString("TranscodingUrl").isNotBlank() - } ?: throw IOException("No playable media source.") + } ?: throw LocalizedIOException(R.string.error_no_playable_source, "No playable media source.") val id = source.optString("Id").takeIf { it.isNotBlank() } ?: itemId val transcode = source.optString("TranscodingUrl").takeIf { it.isNotBlank() } val directStream = source.optString("DirectStreamUrl").takeIf { it.isNotBlank() } @@ -128,8 +140,8 @@ class JellyfinClient(private val session: SessionStore, private val preferences: source.optBoolean("SupportsDirectPlay") && transcode == null -> Urls.directStreamUrl(credentials.serverUrl, itemId, id) transcode != null -> Urls.resolveRelative(credentials.serverUrl, transcode) directStream != null -> Urls.resolveRelative(credentials.serverUrl, directStream) - source.optBoolean("SupportsDirectStream") || source.optBoolean("SupportsTranscoding") -> throw IOException("Server did not supply a negotiated media URL.") - else -> throw IOException("Media source is not playable on this device.") + source.optBoolean("SupportsDirectStream") || source.optBoolean("SupportsTranscoding") -> throw LocalizedIOException(R.string.error_no_playable_source, "Server did not supply a negotiated media URL.") + else -> throw LocalizedIOException(R.string.error_no_playable_source, "Media source is not playable on this device.") } val streams = source.optJSONArray("MediaStreams") ?: JSONArray() val subtitle = (0 until streams.length()).mapNotNull { streams.optJSONObject(it) } @@ -169,8 +181,24 @@ class JellyfinClient(private val session: SessionStore, private val preferences: } suspend fun sendCommand(sessionId: String, command: String) = withContext(Dispatchers.IO) { transportText("Sessions/${urlEncode(sessionId)}/Playing/${urlEncode(command)}", "POST", "{}") } - suspend fun setFavorite(itemId: String, favorite: Boolean) = withContext(Dispatchers.IO) { transportText("Users/${session.userId}/FavoriteItems/${urlEncode(itemId)}${if (favorite) "" else "/Delete"}", "POST", "{}") } - suspend fun setPlayed(itemId: String, played: Boolean) = withContext(Dispatchers.IO) { transportText("Users/${session.userId}/PlayedItems/${urlEncode(itemId)}${if (played) "" else "/Delete"}", "POST", "{}") } + // Jellyfin marks with POST and unmarks with DELETE on the same resource path. + suspend fun setFavorite(itemId: String, favorite: Boolean) = withContext(Dispatchers.IO) { + transportText("Users/${session.userId}/FavoriteItems/${urlEncode(itemId)}", if (favorite) "POST" else "DELETE") + } + suspend fun setPlayed(itemId: String, played: Boolean) = withContext(Dispatchers.IO) { + transportText("Users/${session.userId}/PlayedItems/${urlEncode(itemId)}", if (played) "POST" else "DELETE") + } + + /** + * Revokes the current access token on the server. Best effort and detached: + * the local sign-out never waits for it, and a failure leaves only a stale + * server-side session that the administrator can remove. + */ + fun logout() { + if (!session.isLoggedIn) return + val bound = transportSnapshot() + background.launch { runCatching { bound.json("Sessions/Logout", "POST") } } + } private suspend fun getJson(path: String, method: String = "GET", body: String? = null): JSONObject = JSONObject(transportText(path, method, body)) private suspend fun postSessionEvent(path: String, itemId: String, playSessionId: String, extra: JSONObject.() -> Unit) = withContext(Dispatchers.IO) { @@ -183,5 +211,8 @@ class JellyfinClient(private val session: SessionStore, private val preferences: private fun syncTransport() { transport.update(ServerCredentials(session.serverUrl, session.userId, session.accessToken)) } private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8") - companion object { const val VERSION = BuildConfig.VERSION_NAME } + companion object { + const val VERSION = BuildConfig.VERSION_NAME + const val CHILD_PAGE_SIZE = 60 + } } diff --git a/app/src/main/java/zepigit/firefin/app/data/Models.kt b/app/src/main/java/zepigit/firefin/app/data/Models.kt index 852eef37..5903cb96 100644 --- a/app/src/main/java/zepigit/firefin/app/data/Models.kt +++ b/app/src/main/java/zepigit/firefin/app/data/Models.kt @@ -25,6 +25,10 @@ data class MediaItem( val isSeries: Boolean get() = type == "Series" val isPlayable: Boolean get() = type == "Movie" || type == "Episode" || type == "Video" + /** `S03E04` for numbered episodes, null otherwise. */ + val episodeCode: String? get() = + if (isEpisode && indexNumber > 0) String.format(java.util.Locale.ROOT, "S%02dE%02d", parentIndexNumber, indexNumber) else null + companion object { fun from(json: JSONObject): MediaItem { val userData = json.optJSONObject("UserData") ?: JSONObject() diff --git a/app/src/main/java/zepigit/firefin/app/data/PageCursor.kt b/app/src/main/java/zepigit/firefin/app/data/PageCursor.kt new file mode 100644 index 00000000..4c860c72 --- /dev/null +++ b/app/src/main/java/zepigit/firefin/app/data/PageCursor.kt @@ -0,0 +1,37 @@ +package zepigit.firefin.app.data + +/** + * Paging state for one remote list (main thread only): the server total, the + * next start index and at most one page in flight. A failed page leaves the + * loaded pages untouched and can be requested again. + */ +class PageCursor(private val prefetch: Int = 8) { + var total = -1 + private set + var loaded = 0 + private set + private var inFlight = false + + val hasMore: Boolean get() = total < 0 || loaded < total + + /** Start index of the next page, or null while a page is in flight or nothing is left. */ + fun next(): Int? { + if (inFlight || !hasMore) return null + inFlight = true + return loaded + } + + /** Records a received page; an empty page ends the list even if the server total says otherwise. */ + fun received(count: Int, serverTotal: Int) { + inFlight = false + loaded += count + total = if (count == 0) loaded else maxOf(serverTotal, loaded) + } + + fun failed() { + inFlight = false + } + + /** True when [position] is close enough to the loaded end to fetch the next page. */ + fun nearEnd(position: Int): Boolean = hasMore && position >= loaded - prefetch +} diff --git a/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt b/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt index 439f7ab6..ea69f50e 100644 --- a/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt +++ b/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt @@ -13,23 +13,40 @@ import okhttp3.Request import okhttp3.Response import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.MediaType.Companion.toMediaType +import okhttp3.tls.HandshakeCertificates +import zepigit.firefin.app.R import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -class SessionExpiredException : IOException("Your session has expired. Please sign in again.") -class ServerResponseException(val status: Int) : IOException("Server request failed (HTTP $status).") +class SessionExpiredException : LocalizedIOException(R.string.error_session_expired, "Your session has expired. Please sign in again.") +class ServerResponseException(val status: Int) : LocalizedIOException(R.string.error_server_status, "Server request failed (HTTP $status).", listOf(status)) class ServerCredentials(val serverUrl: String, val userId: String, val token: String) -/** A single origin-bound, cancellable HTTP stack shared by REST, images and Media3. */ -class ServerTransport(initial: ServerCredentials, val deviceId: String, private val deviceName: String = "Fire TV", private val version: String = "0.1.0-firefin", private val shared: ServerTransport? = null) { +/** + * A single origin-bound, cancellable HTTP stack shared by REST, images and Media3. + * + * [trust] adds public trust anchors to the platform store (see ServiceLocator); + * chain and hostname validation stay with the platform verifier. Snapshots + * inherit the trust and media read timeout of the transport they were taken from. + */ +class ServerTransport( + initial: ServerCredentials, + val deviceId: String, + private val deviceName: String = "Fire TV", + private val version: String = "0.1.0-firefin", + private val shared: ServerTransport? = null, + private val trust: HandshakeCertificates? = shared?.trust, + private val mediaReadTimeoutMs: Long = shared?.mediaReadTimeoutMs ?: MEDIA_READ_TIMEOUT_MS, +) { @Volatile private var current = initial private val lock = Any() private fun baseBuilder() = OkHttpClient.Builder() .apply { shared?.let { dispatcher(it.http.dispatcher); connectionPool(it.http.connectionPool) } } + .apply { trust?.let { sslSocketFactory(it.sslSocketFactory(), it.trustManager) } } .connectTimeout(10, TimeUnit.SECONDS) .followRedirects(false) .followSslRedirects(false) @@ -48,10 +65,15 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private chain.proceed(request) }).build() - /** Media3 keeps long-running VOD bodies open while sharing origin/auth policy. */ + /** + * Media3 keeps long-running VOD bodies open while sharing origin/auth policy. + * The call itself is unbounded, but a single read that receives no bytes for + * [mediaReadTimeoutMs] fails, so a stalled connection reaches Media3's retry + * and error handling instead of buffering forever. + */ val mediaHttp: OkHttpClient = baseBuilder() .callTimeout(0, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(mediaReadTimeoutMs, TimeUnit.MILLISECONDS) .addInterceptor(Interceptor { chain -> var request = chain.request() var redirects = 0 @@ -147,7 +169,7 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private val text = response.use { val body = it.body ?: return@use JsonResponse(it.code, "") val limit = if (it.isSuccessful) MAX_JSON_BYTES else 64L * 1024 - if (it.isSuccessful && body.contentLength() > limit) throw IOException("Server response is too large.") + if (it.isSuccessful && body.contentLength() > limit) throw LocalizedIOException(R.string.error_response_too_large, "Server response is too large.") val buffer = java.io.ByteArrayOutputStream() body.byteStream().use { stream -> val chunk = ByteArray(16 * 1024) @@ -157,7 +179,7 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private if (read < 0) break total += read if (total > limit) { - if (it.isSuccessful) throw IOException("Server response is too large.") + if (it.isSuccessful) throw LocalizedIOException(R.string.error_response_too_large, "Server response is too large.") buffer.write(chunk, 0, (read - (total - limit)).toInt()) break } @@ -190,5 +212,8 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private val JSON = "application/json; charset=utf-8".toMediaType() val REDIRECTS = setOf(301, 302, 303, 307, 308) const val MAX_JSON_BYTES = 8L * 1024 * 1024 + // Jellyfin can hold an HLS segment request until the transcoder has + // written it; 30 s stays well above that delay on slow servers. + const val MEDIA_READ_TIMEOUT_MS = 30_000L } } diff --git a/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt b/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt index caa1c6a8..36262c53 100644 --- a/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt +++ b/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt @@ -40,11 +40,10 @@ class SessionStore( val isLoggedIn: Boolean get() = serverUrl.isNotEmpty() && accessToken.isNotEmpty() && userId.isNotEmpty() + /** Signs out: removes the account and token, keeps server address and user name to prefill the next sign-in. */ fun clear() { prefs.edit() - .remove(KEY_SERVER) .remove(KEY_USER_ID) - .remove(KEY_USER_NAME) .remove(KEY_SERVER_NAME) .remove(KEY_TOKEN) .apply() diff --git a/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt b/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt index 94140f8b..436ed09a 100644 --- a/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt +++ b/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt @@ -103,6 +103,8 @@ class ImageLoader(context: Context, http: okhttp3.OkHttpClient, cacheName: Strin val file = diskFile(load.key) if (!file.exists()) return null if (file.length() > MAX_IMAGE_BYTES) { file.delete(); return null } + // Touch on read so the size trim removes the least recently used files first. + file.setLastModified(System.currentTimeMillis()) runCatching { readBounded(file.inputStream()) }.getOrNull() } ?: return null val bitmap = runCatching { decode(bytes, load.width) }.getOrNull() @@ -142,6 +144,11 @@ class ImageLoader(context: Context, http: okhttp3.OkHttpClient, cacheName: Strin if (zepigit.firefin.app.BuildConfig.DEBUG && !load.cancelled) android.util.Log.w("FirefinArtwork", failure.javaClass.simpleName) if (!load.cancelled) failures.put(load.url, System.currentTimeMillis()) null + } catch (failure: OutOfMemoryError) { + // A single oversized artwork must not end the shared decode workers on a 1 GB device. + memCache.evictAll() + if (!load.cancelled) failures.put(load.url, System.currentTimeMillis()) + null } finally { load.call = null } private fun readBounded(input: java.io.InputStream): ByteArray? = input.use { stream -> diff --git a/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt b/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt index 909f4eeb..069b77b5 100644 --- a/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt +++ b/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt @@ -6,6 +6,9 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -43,13 +46,32 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { .setPreferredAudioLanguage(prefs.stored.audioLanguage.takeIf { it.isNotBlank() }) .setPreferredTextLanguage(prefs.stored.subtitleLanguage.takeIf { it.isNotBlank() }) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, prefs.stored.subtitleLanguage.isBlank()).build() + addListener(object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) = selectExternalSubtitle(tracks) + }) } + /** True until the sideloaded subtitle of the current item has been selected explicitly. */ + private var externalSubtitlePending = false + var currentUrl: String? = null private set + /** + * Applies a negotiated subtitle choice: text on or off, preferring [language]. + * Call before [prepare]; the sideloaded file is then pinned once its track appears. + */ + fun applySubtitleChoice(enabled: Boolean, language: String?) { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !enabled) + .setPreferredTextLanguage(language?.takeIf { it.isNotBlank() }) + .build() + } + fun prepare(url: String, startMs: Long, subtitleUrl: String? = null, subtitleMime: String? = null) { currentUrl = url + externalSubtitlePending = subtitleUrl != null && subtitleMime != null val isHls = url.contains(".m3u8") || url.contains("/hls/") val mediaItem = MediaItem.Builder() .setUri(url) @@ -57,7 +79,7 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { .apply { if (subtitleUrl != null && subtitleMime != null) setSubtitleConfigurations(listOf( MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitleUrl)) - .setMimeType(subtitleMime).setSelectionFlags(C.SELECTION_FLAG_DEFAULT).build(), + .setId(EXTERNAL_SUBTITLE_ID).setMimeType(subtitleMime).setSelectionFlags(C.SELECTION_FLAG_DEFAULT).build(), )) }.build() player.setMediaItem(mediaItem, startMs) @@ -65,6 +87,25 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { player.playWhenReady = true } + /** + * A direct-played file can carry embedded subtitles in the same language; + * the user picked the server-delivered file, so select that track by its id. + */ + private fun selectExternalSubtitle(tracks: Tracks) { + if (!externalSubtitlePending) return + for (group in tracks.groups) { + if (group.type != C.TRACK_TYPE_TEXT) continue + for (index in 0 until group.length) { + if (group.getTrackFormat(index).id?.endsWith(EXTERNAL_SUBTITLE_ID) != true) continue + externalSubtitlePending = false + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, index)) + .build() + return + } + } + } + fun attach(surface: SurfaceView) { player.setVideoSurfaceView(surface) } @@ -72,4 +113,8 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { fun release() { player.release() } + + private companion object { + const val EXTERNAL_SUBTITLE_ID = "firefin-external-subtitle" + } } diff --git a/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt b/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt index f1b914bb..b91bc2ca 100644 --- a/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt +++ b/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt @@ -17,6 +17,8 @@ import zepigit.firefin.app.util.Urls class MediaCardAdapter( private val posterStyle: Boolean = true, + /** Episodes inside a season list show only their episode number, not the series name. */ + private val inSeason: Boolean = false, private val onFocus: (MediaItem) -> Unit = {}, private val artwork: ((MediaItem, ImageView) -> Unit)? = null, private val onClick: (MediaItem) -> Unit, @@ -32,6 +34,7 @@ class MediaCardAdapter( val unique = newItems.filter { next -> items.none { it.id == next.id } } val start = items.size; items.addAll(unique); notifyItemRangeInserted(start, unique.size) } + fun positionOf(item: MediaItem) = items.indexOfFirst { it.id == item.id } override fun getItemCount() = items.size override fun getItemId(position: Int) = items[position].id.hashCode().toLong() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = Holder( @@ -39,7 +42,12 @@ class MediaCardAdapter( ) override fun onBindViewHolder(holder: Holder, position: Int) { val item = items[position] - holder.name.text = if ((item.isEpisode || item.type == "Season") && item.seriesName.isNotEmpty()) "${item.seriesName} · ${item.name}" else item.name + holder.name.text = when { + item.isEpisode && inSeason -> listOfNotNull(item.indexNumber.takeIf { it > 0 }?.let { String.format(java.util.Locale.ROOT, "E%02d", it) }, item.name).joinToString(" · ") + item.isEpisode -> listOfNotNull(item.seriesName.ifBlank { null }, item.episodeCode, item.name).joinToString(" · ") + item.type == "Season" && item.seriesName.isNotEmpty() -> "${item.seriesName} · ${item.name}" + else -> item.name + } holder.subtitle.text = buildString { if (item.year > 0) append(item.year) if (item.played) append(" ✓ ").append(holder.itemView.context.getString(R.string.mark_watched)) @@ -59,23 +67,17 @@ class MediaCardAdapter( ServiceLocator.images.load(url, ArtworkPolicy.decodeBucket(width), holder.image) } holder.card.setOnClickListener { onClick(item) } - holder.card.setOnFocusChangeListener { view, focused -> - val expand = ServiceLocator.preferences.effective().cardFocusExpansion - val scale = if (expand && focused) 1.05f else 1f - view.animate().scaleX(scale).scaleY(scale).setDuration(if (expand) 150 else 0).start() + // Card focus expansion is locked off on this target (EffectiveDevicePreferences); + // focus only starts the title marquee and notifies the screen. + holder.card.setOnFocusChangeListener { _, focused -> holder.name.isSelected = focused if (focused) onFocus(item) } - val expand = ServiceLocator.preferences.effective().cardFocusExpansion - holder.card.scaleX = if (expand && holder.card.hasFocus()) 1.05f else 1f - holder.card.scaleY = holder.card.scaleX holder.name.isSelected = holder.card.hasFocus() } override fun onViewRecycled(holder: Holder) { ServiceLocator.images.cancel(holder.image) - holder.card.animate().cancel() holder.card.setOnFocusChangeListener(null) - holder.card.scaleX = 1f; holder.card.scaleY = 1f super.onViewRecycled(holder) } class Holder(view: View) : RecyclerView.ViewHolder(view) { diff --git a/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt b/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt index 47a52067..9a1304c9 100644 --- a/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt +++ b/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.View import android.widget.Button import android.widget.ImageView +import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity @@ -20,11 +21,12 @@ import kotlinx.coroutines.launch import zepigit.firefin.app.R import zepigit.firefin.app.ServiceLocator import zepigit.firefin.app.data.MediaItem +import zepigit.firefin.app.data.PageCursor import zepigit.firefin.app.images.ArtworkPolicy import zepigit.firefin.app.util.Ticks import zepigit.firefin.app.util.Urls -/** Item details with backdrop, play/resume, favorite/watched and episode children. */ +/** Item details with backdrop, play/resume, favorite/watched and paged children. */ class DetailActivity : AppCompatActivity() { private val scope = CoroutineScope(Dispatchers.Main + Job()) @@ -33,21 +35,37 @@ class DetailActivity : AppCompatActivity() { private var firstResume = true private var loadJob: Job? = null private var startingPlayback = false + private var childCursor = PageCursor() + private var childAdapter: MediaCardAdapter? = null + + private lateinit var backdrop: ImageView + private lateinit var name: TextView + private lateinit var meta: TextView + private lateinit var overview: TextView + private lateinit var play: Button + private lateinit var restart: Button + private lateinit var favorite: Button + private lateinit var watched: Button + private lateinit var retry: Button + private lateinit var progress: ProgressBar + private lateinit var children: RecyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_detail) itemId = intent.getStringExtra(EXTRA_ID) ?: return finish() - val backdrop = findViewById(R.id.backdrop) - val name = findViewById(R.id.name) - val meta = findViewById(R.id.meta) - val overview = findViewById(R.id.overview) - val play = findViewById