From 0d4e13ab264bb0483ce93ff3c561bc1c9334ddb6 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Mon, 26 Oct 2026 08:16:02 -0500 Subject: [PATCH] Add Plex and Jellyfin clients and audit APK sources --- CONTRIBUTING.md | 23 ++++- apps_config.json | 19 +++- docs/catalog-source-audit-2026-10-26.md | 116 +++++++++++++++++++++++ test/apps_config_test.dart | 50 ++++++++++ tool/audit_catalog.dart | 119 ++++++++++++++++++++++++ 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 docs/catalog-source-audit-2026-10-26.md create mode 100644 test/apps_config_test.dart create mode 100644 tool/audit_catalog.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 179d6fd..d9d276f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,8 +59,7 @@ Applications are defined in `apps_config.json`: "asset_pattern": "example-*-arm64.apk", "package_id": "com.example.app", "sha256": "optional-lowercase-sha256", - "description": "Description shown in OpenPelo", - "abi": "arm64-v8a" + "description": "Description shown in OpenPelo" } } } @@ -75,7 +74,25 @@ Applications are defined in `apps_config.json`: incompatible-signature reinstall. - `sha256` is an optional integrity checksum. Add it whenever the URL points to an immutable, versioned APK. -- `abi` is `arm64-v8a` or `armeabi-v7a`. +- Minimum Android API, native ABIs, package ID, and version are read from the + resolved APK. Static `abi` fields are not used. A successful probe establishes + metadata compatibility, not working playback or hardware compatibility. + +Audit sources without connecting a device or installing APKs: + +```powershell +dart run tool/audit_catalog.dart +``` + +The command uses the production release asset selector and bounded APK probe, +prints one JSON record per app, and exits nonzero if any source is unverified. +An optional argument selects another catalog JSON file. GitHub metadata is +cached within the run; on Windows it uses curl and the Windows certificate store. +An unverified result is not necessarily a broken URL: inspect redirects and +HTTP status to distinguish unavailable assets, access restrictions, timeouts, +and servers without byte-range support. Do not bypass authentication or bot +challenges. See the [2026-10-26 audit](docs/catalog-source-audit-2026-10-26.md) +for the current source choices and limitations. ## Automated builds diff --git a/apps_config.json b/apps_config.json index 20fc4b5..0002e3d 100644 --- a/apps_config.json +++ b/apps_config.json @@ -198,6 +198,22 @@ "asset_name": "fennec-68.11.0.en-US.android-arm.apk", "description": "Firefox ESR 68.11 - Last version supporting Android 4.4+ and Gen 1" }, + "Plex": { + "category": "Media & streaming", + "url": "https://downloads.plex.tv/Kepler/kepler-arm64-v8a-googlePlay-10.26.0.2578-6cc7ea1a.apk", + "asset_name": "kepler-arm64-v8a-googlePlay-10.26.0.2578-6cc7ea1a.apk", + "package_id": "com.plexapp.android", + "sha256": "f7271488dd931a720cf7965a36af25c5cd45c449d1dcea7edb16618e7a6ee010", + "description": "Archived Plex Android mobile client 10.26.0 (ARM64, Android 6+), before the redesigned mobile app.\nOfficial Plex download; Plex Pass required per the publisher. Personal media requires access to a Plex Media Server." + }, + "Jellyfin": { + "category": "Media & streaming", + "url": "https://api.github.com/repos/jellyfin/jellyfin-android/releases/latest", + "asset_name": "jellyfin-android-v2.7.3-libre-release.apk", + "asset_pattern": "jellyfin-android-v*-libre-release.apk", + "package_id": "org.jellyfin.mobile", + "description": "Official Jellyfin Android mobile client (Libre build). Requires access to a Jellyfin server.\nUses Android System WebView; this is the phone/tablet client, not Jellyfin for Android TV." + }, "Emby": { "category": "Media & streaming", "url": "https://github.com/MediaBrowser/Emby.Releases/raw/master/android/emby-android-google-arm64-v8a-release.apk", @@ -232,8 +248,9 @@ }, "VLC Legacy": { "category": "Media & streaming", - "url": "https://get.videolan.org/vlc-android/3.2.12/VLC-Android-3.2.12-armeabi-v7a.apk", + "url": "https://download.videolan.org/pub/videolan/vlc-android/3.2.12/VLC-Android-3.2.12-armeabi-v7a.apk", "asset_name": "VLC-Android-3.2.12-armeabi-v7a.apk", + "sha256": "685ef95a9d0279d3231c4a8d9982cc47e06f1931683bece0c4711a4804bfb6dc", "description": "VLC media player v3.2.12 for Gen 1 - Plays almost any video/audio format" }, "Simple File Manager": { diff --git a/docs/catalog-source-audit-2026-10-26.md b/docs/catalog-source-audit-2026-10-26.md new file mode 100644 index 0000000..e4505f6 --- /dev/null +++ b/docs/catalog-source-audit-2026-10-26.md @@ -0,0 +1,116 @@ +# Application source audit — 2026-10-26 + +Audited all 39 entries from `main` at `27ca5f1`, then the two new entries and +the replacement VLC URL. No APKs were installed and no device was connected. +The isolated checkout preserves the existing OpenRide and QZ additions. + +## Changes + +- **Plex:** add the archived Android mobile client **10.26.0.2578**, ARM64, + minimum API 23 (Android 6), package `com.plexapp.android`. The APK comes + directly from `downloads.plex.tv`, linked in Plex's + [official 10.26.0 announcement](https://forums.plex.tv/t/plex-for-android/29115/525). + Its description explicitly says archived and retains the publisher's + **Plex Pass required** notice. This is the Google Play mobile-capable Kepler + build from before the redesigned mobile client, not a newer TV-only APK. + The [current mobile announcements](https://forums.plex.tv/t/909610) point to + Google Play, and Plex's public downloads API did not list an Android APK. + This entry therefore does **not** promise the latest mobile version or + automatic version discovery. The published CDN URL responded without login; + no credentials, cookies, access-control bypass, or third-party mirror were used. +- **Jellyfin:** add the official [Android mobile client](https://github.com/jellyfin/jellyfin-android), + using the latest stable release's `jellyfin-android-v*-libre-release.apk`. + The narrow pattern excludes debug APKs, proprietary builds, Android App + Bundles, and the separate [Android TV client](https://github.com/jellyfin/jellyfin-androidtv). + At audit time it selected **2.7.3**, package `org.jellyfin.mobile`, minimum + API 21 (Android 5), with ARM32, ARM64, x86, and x86_64 native libraries. + It requires a Jellyfin server and Android System WebView. +- **VLC Legacy:** replace the `get.videolan.org` mirror dispatcher with + [VideoLAN's official archive](https://download.videolan.org/pub/videolan/vlc-android/3.2.12/VLC-Android-3.2.12-armeabi-v7a.apk). + The dispatcher returned HTTP 200 to a byte-range request, which the production + probe correctly rejects. Its HEAD redirect reached a live mirror; this was + **range-incompatible delivery, not a confirmed 404**. The official archive + passed the same production probe for version 3.2.12, minimum API 17, ARM32. + The version is unchanged. +- Pin SHA-256 for the two immutable Plex and VLC URLs after downloading their + complete APKs locally and computing the hashes. No APK binaries are committed. + +## Method and limits + +`dart run tool/audit_catalog.dart` resolves latest GitHub releases using the +production `selectApkAssetName`, then calls the production `ApkProbeService`. +It checks HTTPS redirects, bounded byte ranges, ZIP metadata and Android manifest +fields. Exact filenames take priority; otherwise the configured glob must match +one APK (or a release with only one APK can be selected). Direct versioned URLs +remain pinned; they do not discover newer versions automatically. + +OpenPelo derives minimum SDK, native ABIs, package ID and version from each APK, +then filters against the selected device's API and supported ABIs. It caches +successful probes for 24 hours and retries unavailable sources after one hour. +The catalog refresh button forces a recheck. Static `abi` fields are ignored. +The production installer also rechecks downloaded APKs and configured hashes. + +All original sources other than the VLC dispatcher passed the live metadata +probe, including OpenGApps, F-Droid, Mozilla, GitHub and Steam. No 404/410, +login gate or bot challenge was observed in those successful checks. This does +not guarantee future availability: rate limits, bot protection, redirects and +upstream release asset names can change. An HTTP error alone should not be +classified as a dead link or worked around using an arbitrary mirror. + +Working pinned releases were retained, including older Firefox, launcher, +file-manager and media builds. Accessibility of an old APK does not establish +that it is maintained or that its online services still work. Existing “Gen 1” +descriptions are not evidence of compatibility (some of those APKs require API +21 or 23); the manifest-based filter remains authoritative for SDK/ABI checks. +No playback, sign-in, DRM, Google services, WebView behavior, server compatibility, +or Peloton hardware functionality was tested. In particular, Plex is an archived +client with no verified current mobile APK update path in this catalog. + +## Observed APK metadata + +All rows below passed the production probe; VLC uses the replacement archive. +`none` means no native libraries were found, not a hardware test. + +| Application | Version | Minimum API | Native ABIs | +| --- | --- | ---: | --- | +| SmartSpin2k | 2.0.5 | 24 | arm64-v8a, armeabi-v7a, x86_64 | +| Grupetto | v0.0.43 | 21 | none | +| OpenRide | 1.0.0-real | 29 | arm64-v8a | +| QZ Standard Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ NordicTrack Treadmill Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ NordicTrack Bike Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ NordicTrack Rower Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ FitPro Treadmill Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ FitPro Bike Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ FitPro Elliptical Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ FitPro Rower Nightly | 2.22.0 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ Peloton Bike Nightly | 2.21.6 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| QZ Peloton Bike+ Nightly | 2.21.5 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| Google Text-to-Speech | googletts.google-speech-apk_20220321.02_p2.439618283 | 23 | arm64-v8a | +| Google Text-to-Speech Legacy | 3.16.6.232892818 | 19 | armeabi-v7a | +| Lawnchair Launcher | 15.Beta 2 | 26 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| Kvaesitso | 1.35.4-fdroid | 26 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| FireFox Focus | 133.0.3 | 21 | arm64-v8a | +| Fulguris | 2.0.10 | 21 | none | +| EinkBro | 16.7.1 | 24 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| Aurora Store | 4.7.5 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| FDroid | 2.0.2 | 24 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| Obtanium | 1.7.3 | 26 | arm64-v8a | +| Moonlight Streaming | 12.1 | 16 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| MaterialFiles | 1.7.5 | 23 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| KISS Launcher | 3.21.3 | 15 | none | +| Lawnchair Launcher Gen 1 | 1.2.0.1884 | 21 | none | +| Firefox ESR | 68.11.0 | 16 | armeabi-v7a | +| Emby | 3.5.71 | 21 | arm64-v8a | +| LibreTube | 32.1 | 26 | none | +| NewPipe | 0.29.1 | 23 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| SkyTubeLegacy | 2.999 | 14 | none | +| NewPipe Legacy | 0.21.16 | 19 | none | +| Simple File Manager | 6.16.1 | 23 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| Simple Gallery | 6.28.1 | 23 | arm64-v8a, armeabi, armeabi-v7a, mips, mips64, x86, x86_64 | +| Amaze File Manager | 3.10 | 19 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| RadioDroid | 0.86 | 16 | none | +| Steam Link | 1.4.3 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| Plex | 10.26.0.2578 | 23 | arm64-v8a | +| Jellyfin | 2.7.3 | 21 | arm64-v8a, armeabi-v7a, x86, x86_64 | +| VLC Legacy | 3.2.12 | 17 | armeabi-v7a | diff --git a/test/apps_config_test.dart b/test/apps_config_test.dart new file mode 100644 index 0000000..1bb8ba1 --- /dev/null +++ b/test/apps_config_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openpelo/models/app_model.dart'; +import 'package:openpelo/services/release_asset_selector.dart'; + +void main() { + final catalog = + (jsonDecode(File('apps_config.json').readAsStringSync()) + as Map)['apps'] + as Map; + + test('bundled catalog has usable HTTPS APK source definitions', () { + for (final entry in catalog.entries) { + final app = AppModel.fromJson(entry.key, entry.value); + final uri = Uri.parse(app.url); + expect(uri.scheme, 'https', reason: app.name); + expect(uri.host, isNotEmpty, reason: app.name); + expect(app.description, isNotEmpty, reason: app.name); + expect(app.category, isNot('Other'), reason: app.name); + expect(app.assetName, endsWith('.apk'), reason: app.name); + if (app.sha256 != null) { + expect(app.sha256, matches(r'^[a-f0-9]{64}$'), reason: app.name); + } + } + }); + + test( + 'Jellyfin selects only the mobile Libre release APK after an update', + () { + final app = AppModel.fromJson('Jellyfin', catalog['Jellyfin']); + expect( + selectApkAssetName( + [ + 'jellyfin-android-v2.8.0-libre-debug.apk', + 'jellyfin-android-v2.8.0-libre-release.aab', + 'jellyfin-android-v2.8.0-libre-release.apk', + 'jellyfin-android-v2.8.0-proprietary-debug.apk', + 'jellyfin-android-v2.8.0-proprietary-release.apk', + 'jellyfin-androidtv-v0.20.0-release.apk', + ], + exactName: app.assetName, + globPattern: app.assetPattern, + ), + 'jellyfin-android-v2.8.0-libre-release.apk', + ); + }, + ); +} diff --git a/tool/audit_catalog.dart b/tool/audit_catalog.dart new file mode 100644 index 0000000..4d6fef3 --- /dev/null +++ b/tool/audit_catalog.dart @@ -0,0 +1,119 @@ +// Run from the repository root: dart run tool/audit_catalog.dart [config.json] +// Read-only network audit; never starts ADB or installs an APK. +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import 'package:openpelo/models/app_model.dart'; +import 'package:openpelo/services/apk_probe_service.dart'; +import 'package:openpelo/services/release_asset_selector.dart'; + +Future main(List args) async { + final config = + jsonDecode( + await File( + args.isEmpty ? 'apps_config.json' : args.single, + ).readAsString(), + ) + as Map; + final releases = >>{}; + final probe = ApkProbeService(); + try { + for (final entry in (config['apps'] as Map).entries) { + final app = AppModel.fromJson(entry.key, entry.value); + final record = {'app': app.name, 'source': app.url}; + try { + var uri = Uri.parse(app.url); + if (uri.host == 'github.com' && + uri.pathSegments.length >= 2 && + uri.pathSegments.contains('releases') && + uri.pathSegments.contains('latest')) { + uri = Uri.https( + 'api.github.com', + '/repos/${uri.pathSegments.take(2).join('/')}/releases/latest', + ); + } + if (uri.host == 'api.github.com') { + final release = await releases.putIfAbsent( + uri.toString(), + () => readRelease(uri), + ); + final assets = release['assets'] as List; + final name = selectApkAssetName( + assets.map((asset) => asset['name'] as String), + exactName: app.assetName, + globPattern: app.assetPattern, + ); + uri = Uri.parse( + assets.singleWhere( + (asset) => asset['name'] == name, + )['browser_download_url'] + as String, + ); + } + // Keep the stable URL, not the expiring signed CDN redirect. + record['download'] = uri.toString(); + final result = await probe.probe(uri); + record['metadata'] = result.metadata.toJson(); + record['bytes'] = result.contentLength; + record['status'] = 'verified'; + } catch (error) { + record['status'] = 'unverified'; + record['error'] = error.toString(); + exitCode = 1; + } + stdout.writeln(jsonEncode(record)); + } + } finally { + probe.close(); + } +} + +Future> readRelease(Uri uri) async { + String body; + if (Platform.isWindows) { + // Use the Windows certificate store, as the app's TLS fallback does. + final result = await Process.run('curl.exe', [ + '--fail', + '--silent', + '--show-error', + '--location', + '--proto', + '=https', + '--proto-redir', + '=https', + '--max-time', + '30', + '--max-filesize', + '8388608', + '-H', + 'User-Agent: OpenPelo/1.0', + '-H', + 'Accept: application/vnd.github+json', + uri.toString(), + ]); + if (result.exitCode != 0) { + throw HttpException('Release lookup failed: ${result.stderr}', uri: uri); + } + body = result.stdout as String; + } else { + final response = await http + .get( + uri, + headers: { + 'User-Agent': 'OpenPelo/1.0', + 'Accept': 'application/vnd.github+json', + }, + ) + .timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) { + throw HttpException( + 'Release lookup HTTP ${response.statusCode}', + uri: uri, + ); + } + body = response.body; + } + return jsonDecode(body) as Map; +}