Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand All @@ -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

Expand Down
19 changes: 18 additions & 1 deletion apps_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
116 changes: 116 additions & 0 deletions docs/catalog-source-audit-2026-10-26.md
Original file line number Diff line number Diff line change
@@ -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 |
50 changes: 50 additions & 0 deletions test/apps_config_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic>)['apps']
as Map<String, dynamic>;

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',
);
},
);
}
119 changes: 119 additions & 0 deletions tool/audit_catalog.dart
Original file line number Diff line number Diff line change
@@ -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<void> main(List<String> args) async {
final config =
jsonDecode(
await File(
args.isEmpty ? 'apps_config.json' : args.single,
).readAsString(),
)
as Map<String, dynamic>;
final releases = <String, Future<Map<String, dynamic>>>{};
final probe = ApkProbeService();
try {
for (final entry in (config['apps'] as Map<String, dynamic>).entries) {
final app = AppModel.fromJson(entry.key, entry.value);
final record = <String, dynamic>{'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<Map<String, dynamic>> 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<String, dynamic>;
}
Loading