[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

@@ -0,0 +1,72 @@
package com.bbflight.background_downloader;
import android.app.job.JobInfo;
import android.content.ComponentName;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
/** Runs against the real Android 9 framework classes, not stubbed builders. */
public final class DownloadNetworkPolicyProbe {
private static int checks;
private static void check(boolean value, String message) {
checks++;
if (!value) throw new AssertionError(message);
}
public static void main(String[] args) {
// Android's getuid JNI library is not loaded by a plain host JVM.
// Fixture only the process identity; capability matching and JobInfo
// below remain the real unmodified Android framework implementation.
libcore.io.Os originalOs = libcore.io.Libcore.os;
libcore.io.Libcore.os = (libcore.io.Os) java.lang.reflect.Proxy.newProxyInstance(
libcore.io.Os.class.getClassLoader(), new Class<?>[]{libcore.io.Os.class},
(proxy, method, arguments) -> method.getName().equals("getuid")
? 10000 : method.invoke(originalOs, arguments));
NetworkCapabilities cellular = new NetworkCapabilities()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR);
cellular.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
cellular.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN);
NetworkCapabilities wifi = new NetworkCapabilities(cellular)
.removeTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI);
ComponentName service = new ComponentName("org.reelbox.app", "DownloadTestService");
// Reproduce why NETWORK_TYPE_ANY queues despite a reachable media host.
JobInfo legacy = new JobInfo.Builder(1, service)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY).build();
check(legacy.getRequiredNetwork().hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
"Legacy ANY must reproduce the hidden validation requirement");
check(!legacy.getRequiredNetwork().networkCapabilities.satisfiedByNetworkCapabilities(cellular),
"Legacy request should reject an unvalidated internet-capable network");
NetworkRequest any = DownloadNetworkPolicy.request(false);
NetworkRequest wifiOnly = DownloadNetworkPolicy.request(true);
check(any.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), "Internet is still required");
check(!any.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), "No external validation requirement");
check(!any.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN), "VPNs remain allowed");
check(any.networkCapabilities.satisfiedByNetworkCapabilities(cellular), "Unvalidated cellular/VPN allowed");
check(any.networkCapabilities.satisfiedByNetworkCapabilities(wifi), "Unvalidated Wi-Fi/VPN allowed");
check(wifiOnly.hasTransport(NetworkCapabilities.TRANSPORT_WIFI), "Preserve Wi-Fi-only preference");
check(!wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(cellular), "Wi-Fi-only rejects cellular");
check(wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(wifi), "Wi-Fi-only accepts unvalidated Wi-Fi/VPN");
check(!any.networkCapabilities.satisfiedByNetworkCapabilities(new NetworkCapabilities()), "No-internet network rejected");
NetworkCapabilities vpnCellular = new NetworkCapabilities(cellular)
.addTransportType(NetworkCapabilities.TRANSPORT_VPN);
NetworkCapabilities vpnWifi = new NetworkCapabilities(wifi)
.addTransportType(NetworkCapabilities.TRANSPORT_VPN);
check(any.networkCapabilities.satisfiedByNetworkCapabilities(vpnCellular), "Explicit cellular-backed VPN allowed");
check(!wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(vpnCellular), "Wi-Fi-only rejects cellular-backed VPN");
check(wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(vpnWifi), "Explicit Wi-Fi-backed VPN allowed");
check(!wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(
new NetworkCapabilities(wifi).removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)),
"Wi-Fi without internet capability rejected");
JobInfo custom = new JobInfo.Builder(2, service).setRequiredNetwork(any).build();
check(!custom.getRequiredNetwork().hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
"JobScheduler must not re-add validation to an explicit request");
check(custom.getRequiredNetwork().networkCapabilities.satisfiedByNetworkCapabilities(cellular),
"UIDT-style explicit request can use the unvalidated network");
System.out.println("PASS DownloadNetworkPolicy: " + checks + " real Android framework assertions");
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'dart:convert';
import 'dart:io';
import 'package:reelbox/data/api_service.dart';
Future<void> main() async {
final api = ApiService();
await api.initialize();
final home = await api.home();
final movies = await api.catalog();
final series = await api.catalog(kind: 'serie');
if (movies.isEmpty || series.isEmpty) {
throw StateError('Live catalog is empty');
}
// Catalogs can lead with announcements or titles whose sources are pending.
// Still require a real playable-source response, without assuming row zero.
var detail = await api.poster(movies.first.id);
final checked = <Map<String, int>>[
{'id': detail.id, 'source_count': detail.sources.length},
];
for (final candidate in movies.skip(1).take(4)) {
if (detail.sources.isNotEmpty) break;
detail = await api.poster(candidate.id);
checked.add({'id': detail.id, 'source_count': detail.sources.length});
}
final seasons = await api.seasons(series.first.id);
final search = await api.search('Matrix', english: true);
final recommendations = await api.bestRandom();
if (detail.sources.isEmpty || search.isEmpty || recommendations.isEmpty) {
throw StateError('A required public endpoint did not return expected data');
}
stdout.writeln(
jsonEncode({
'api_base': api.baseUrl,
'home_sections': (home['genres'] as List?)?.length,
'movie_page_count': movies.length,
'series_page_count': series.length,
'movie_detail_id': detail.id,
'movie_details_checked': checked,
'movie_source_count': detail.sources.length,
'series_detail_id': series.first.id,
'season_count': seasons.length,
'episode_source_count': seasons.fold<int>(
0,
(sum, s) =>
sum + s.episodes.fold<int>(0, (n, e) => n + e.sources.length),
),
'english_search_results': search.length,
'recommendations': recommendations.length,
'account_used': false,
'media_downloaded': false,
}),
);
}
+25
View File
@@ -0,0 +1,25 @@
// Explicit opt-in live read-only probe, outside default test discovery.
// Only local preference storage is a fixture; API responses are real network data.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_smoke.dart' as probe;
// Use the normal Dart HTTP client instead of Flutter test's deliberate HTTP-400
// client. This does not change TLS validation, credentials or access denials.
class _LiveReadOnlyNetwork extends HttpOverrides {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('live read-only catalog contracts and source availability', () async {
// Explicit test-only harness outside automatic test discovery.
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues({});
await HttpOverrides.runWithHttpOverrides(
probe.main,
_LiveReadOnlyNetwork(),
);
}, timeout: const Timeout(Duration(minutes: 3)));
}
+119
View File
@@ -0,0 +1,119 @@
// Explicit host-rendered UI evidence; not Android-device verification.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/app.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/catalog.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../test/home_banner_widget_test.dart' show HomeBannerFixtureApi;
void main() {
testWidgets(
'actual home Telegram at 390x844 in English/Persian including API error and tap',
(tester) async {
await tester.runAsync(() async {
final bytes = await File(
Platform.environment['REELBOX_TEST_FONT'] ??
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
).readAsBytes();
for (final family in ['Roboto', 'Ahem']) {
await (FontLoader(
family,
)..addFont(Future.value(bytes.buffer.asByteData()))).load();
}
final icons = await File(
Platform.environment['REELBOX_ICON_FONT'] ?? '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf',
).readAsBytes();
await (FontLoader(
'MaterialIcons',
)..addFont(Future.value(icons.buffer.asByteData()))).load();
});
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(390, 844);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final output = Directory(
Platform.environment['REELBOX_SCREENSHOT_DIR'] ??
'${Directory.current.path}/build/banner-ui-verification',
);
await tester.runAsync(() => output.create(recursive: true));
const channel = MethodChannel('plugins.flutter.io/url_launcher');
final targets = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
targets.add(call.arguments['url'] as String);
return true;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
for (final failed in [false, true]) {
for (final locale in ['en', 'fa']) {
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: HomeBannerFixtureApi()..fail = failed,
storage: await SharedPreferences.getInstance(),
);
await state.initialize(discover: false);
await state.updateSettings(
state.prefs.copyWith(locale: locale, introCompleted: true),
);
final boundaryKey = GlobalKey();
await tester.pumpWidget(
RepaintBoundary(
key: boundaryKey,
child: MyApp(state: state),
),
);
await tester.pumpAndSettle();
expect(find.byType(DiscoverPage), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
final title = locale == 'en'
? 'ReelBox updates'
: 'تازه‌های ریل‌باکس';
expect(find.text(title), findsOneWidget);
expect(
tester
.getRect(find.byKey(const Key('home-banner-telegram')))
.bottom,
lessThan(844),
);
if (failed) expect(find.text('Offline'), findsWidgets);
expect(tester.takeException(), isNull);
final boundary =
boundaryKey.currentContext!.findRenderObject()!
as RenderRepaintBoundary;
final name = 'home-banner-$locale${failed ? '-error' : ''}';
await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: 1);
expect(image.width, 390);
expect(image.height, 844);
final bytes = await image.toByteData(
format: ui.ImageByteFormat.png,
);
await File('${output.path}/$name.png')
.writeAsBytes(bytes!.buffer.asUint8List());
image.dispose();
});
await tester.tap(find.byKey(const Key('home-banner-telegram')));
await tester.pumpAndSettle();
expect(targets.last, 'https://t.me/ReelBoxApp');
await tester.pumpWidget(const SizedBox());
state.dispose();
}
}
expect(targets, List.filled(4, 'https://t.me/ReelBoxApp'));
},
);
}
+85
View File
@@ -0,0 +1,85 @@
"""Read-only deployed API checks; never requests any returned media URL."""
import argparse
import hashlib
import json
from pathlib import Path
import sqlite3
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--origin', default='https://reelbox.dartty.top:8443')
parser.add_argument('--database', default='/root/moviebox-export/catalog.sqlite')
parser.add_argument('--output', required=True)
args = parser.parse_args()
db = sqlite3.connect(Path(args.database).resolve().as_uri() + '?mode=ro', uri=True)
timings = {}
def get(path):
start = time.monotonic()
with urlopen(args.origin + path, timeout=40) as response:
assert response.status == 200
assert 'application/json' in response.headers['Content-Type']
value = json.load(response)
timings[path.split('?')[0]] = round(time.monotonic() - start, 3)
return value
health = get('/api/health')
assert health['status'] == 'ok' and health['media_proxy'] is False and health['offline'] is True
for table in ('titles', 'episodes', 'seasons', 'movie_sources', 'episode_sources'):
assert health['counts'][table] == db.execute('SELECT count(*) FROM ' + table).fetchone()[0]
home = get('/api/first/public/')
assert home['genres'][0]['posters']
movies = get('/api/movie/by/filtres/0/created/0/public/')
series = get('/api/serie/by/filtres/0/created/0/public/')
assert movies and series
movie = next((get('/api/movie/by/' + str(row['id']) + '/public/') for row in movies
if db.execute('SELECT 1 FROM movie_sources WHERE title_id=? LIMIT 1', (row['id'],)).fetchone()), None)
assert movie and movie['sources']
expected = [json.loads(r[0]) for r in db.execute('SELECT raw_json FROM movie_sources WHERE title_id=? ORDER BY source_index', (movie['id'],))]
assert movie['sources'] == expected, 'Movie source records changed'
seasons = get('/api/season/by/serie/' + str(series[0]['id']) + '/public/')
expected_seasons = [json.loads(r[0]) for r in db.execute('SELECT raw_json FROM seasons WHERE series_id=? ORDER BY season_index', (series[0]['id'],))]
actual_urls = [source['url'] for season in seasons for ep in season.get('episodes', []) for source in ep.get('sources', [])]
expected_urls = [source['url'] for season in expected_seasons for ep in season.get('episodes', []) for source in ep.get('sources', [])]
assert actual_urls and actual_urls == expected_urls, 'Episode source URLs changed'
for url in actual_urls + [source['url'] for source in movie['sources']]:
from urllib.parse import urlsplit
assert urlsplit(url).hostname not in {'reelbox.dartty.top', '87.107.110.187'}
search = get('/api-user/newapi/like.php?action=search-movie-en&q=Matrix&pageno=1')
assert search
assert get('/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')
assert get('/api/genre/all/public/') and get('/api/country/all/public/')
assert get('/api-user/newapi/pishnahad.php')
rejected = {}
for method, path in [('GET', '/api/proxy?url=https://media.example/movie.mkv'),
('GET', '/api/movie.mkv'), ('GET', '/api/comments/by/poster/1/public/'),
('GET', '/api-user/newapi/like.php?action=search-movie&url=http://127.0.0.1/'),
('GET', '/catalog.sqlite'), ('POST', '/api/health')]:
try:
with urlopen(Request(args.origin + path, method=method), timeout=15) as response:
raise AssertionError('Unexpected accepted request: ' + path + ' status=' + str(response.status))
except HTTPError as error:
assert error.code in {400, 403, 404, 405}
rejected[method + ' ' + path] = error.code
error.close()
with urlopen(args.origin + '/', timeout=15) as response:
assert response.status == 200 and b'Reelbox' in response.read()
report = {'passed': True, 'origin': args.origin, 'health': health,
'movie_id': movie['id'], 'series_id': series[0]['id'],
'movie_source_records_match_snapshot': len(expected),
'episode_source_urls_match_snapshot': len(actual_urls),
'search_matches': len(search), 'rejected_routes': rejected,
'website_still_available': True, 'media_downloaded': False, 'seconds_by_route': timings}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))
db.close()
if __name__ == '__main__':
main()
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Exercise download network requests with real Android 9 framework classes.
This verifies capability matching and JobInfo construction, not a device,
WorkManager execution, internet connectivity, or OEM background scheduling.
"""
from pathlib import Path
import fcntl
import hashlib
import json
import os
import subprocess
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
WORK = Path(os.environ.get('TMPDIR', '/root/.hermes/cache/scratch')) / 'reelbox-network-test'
FRAMEWORK_URL = 'https://repo.maven.apache.org/maven2/org/robolectric/android-all/9-robolectric-4913185-2/android-all-9-robolectric-4913185-2.jar'
FRAMEWORK_SHA256 = 'cf4aed5e458deb48ddc0ca92fc25156e6f478d9d5c1d948cdd1817a67ee11b32'
def main():
WORK.mkdir(parents=True, exist_ok=True)
framework = WORK / 'android-all.jar'
if not framework.exists() or hashlib.sha256(framework.read_bytes()).hexdigest() != FRAMEWORK_SHA256:
with urllib.request.urlopen(FRAMEWORK_URL, timeout=90) as response:
blob = response.read()
assert hashlib.sha256(blob).hexdigest() == FRAMEWORK_SHA256, 'Framework checksum mismatch'
framework.write_bytes(blob)
classes = WORK / 'classes'
classes.mkdir(exist_ok=True)
source = ROOT / 'third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadNetworkPolicy.java'
test = ROOT / 'tool/android/DownloadNetworkPolicyProbe.java'
with (ROOT / '.tooling.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
(WORK / 'verification.json').unlink(missing_ok=True)
command = ['javac', '-J-Xmx128m', '--release', '17', '-cp', str(framework), '-d', str(classes), str(test)]
if source.exists():
command.append(str(source))
subprocess.run(command, check=True, timeout=90)
result = subprocess.run(['java', '-Xmx192m', '-cp', os.pathsep.join([str(classes), str(framework)]),
'com.bbflight.background_downloader.DownloadNetworkPolicyProbe'],
check=False, capture_output=True, text=True, timeout=90)
print(result.stdout, end='')
if result.returncode:
print(result.stderr, end='')
raise subprocess.CalledProcessError(result.returncode, result.args)
assert 'PASS DownloadNetworkPolicy:' in result.stdout
(WORK / 'verification.json').write_text(json.dumps({
'passed': True,
'framework_sha256': FRAMEWORK_SHA256,
'stdout': result.stdout,
'scope': 'real Android 9 framework capability matching and JobInfo construction; no device or WorkManager execution',
'android_hardware_verified': False,
}, indent=2) + '\n')
if __name__ == '__main__':
main()
+162
View File
@@ -0,0 +1,162 @@
// Explicit host-rendered component evidence, not Android-device verification.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_button.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/media/downloads_page.dart';
import 'package:reelbox/state/app_state.dart';
import '../test/catalog_widget_test.dart' show CatalogApi, mountCatalog;
void main() {
testWidgets('download components at 390px in English and Persian', (
tester,
) async {
await tester.runAsync(() async {
final font = await File('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')
.readAsBytes();
for (final family in ['Roboto', 'Ahem']) {
await (FontLoader(
family,
)..addFont(Future.value(font.buffer.asByteData()))).load();
}
final icons = await File(
'/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf',
).readAsBytes();
await (FontLoader(
'MaterialIcons',
)..addFont(Future.value(icons.buffer.asByteData()))).load();
});
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final state = await mountCatalog(tester, CatalogApi(), const Scaffold());
addTearDown(state.dispose);
final boundaryKey = GlobalKey();
final paused = DownloadEntry.fromTask(
DownloadTask(url: 'https://media.example/movie.mp4'),
taskStatus: TaskStatus.paused,
progress: .25,
totalBytes: 104857600,
);
final failed = DownloadEntry.fromTask(
DownloadTask(url: 'https://media.example/expired.mp4'),
taskStatus: TaskStatus.failed,
error: 'HTTP 403: this media URL has expired.',
);
for (final locale in ['en', 'fa']) {
await state.updateSettings(state.prefs.copyWith(locale: locale));
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(
theme: ThemeData.dark(useMaterial3: true).copyWith(
appBarTheme: const AppBarTheme(
titleTextStyle: TextStyle(fontFamily: 'Roboto', fontSize: 20),
),
),
home: RepaintBoundary(
key: boundaryKey,
child: Directionality(
textDirection: locale == 'fa'
? TextDirection.rtl
: TextDirection.ltr,
child: Builder(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text(tr(context, 'Downloads', 'دانلودها')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
tr(context, 'Source · 720p', 'منبع · 720p'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
DownloadButton(
title: const TitleRecord(
id: 7,
title: 'Fixture movie',
),
source: const MediaSource(
quality: '720p',
url: 'https://media.example/movie.mp4',
),
enqueue: ({
required title,
required source,
episode,
}) async {},
),
const SizedBox(height: 18),
for (final entry in [paused, failed])
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
context,
'Fixture movie · 720p',
'فیلم نمونه · 720p',
),
style: Theme.of(context)
.textTheme
.titleMedium,
),
const SizedBox(height: 12),
DownloadTransferDetails(entry: entry),
const SizedBox(height: 8),
DownloadControls(
entry: entry,
onPause: () async {},
onResume: () async {},
onRetry: () async {},
onCancel: () async {},
),
],
),
),
),
],
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(DownloadButton), findsOneWidget);
expect(find.byType(DownloadTransferDetails), findsNWidgets(2));
expect(tester.takeException(), isNull);
final boundary =
boundaryKey.currentContext!.findRenderObject()!
as RenderRepaintBoundary;
await tester.runAsync(() async {
final directory = Directory(
'/root/reelbox/build/download-ui-verification',
);
await directory.create(recursive: true);
final image = await boundary.toImage(pixelRatio: 2);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
await File('${directory.path}/downloads-$locale.png')
.writeAsBytes(bytes!.buffer.asUint8List());
image.dispose();
});
}
await tester.pumpWidget(const SizedBox());
});
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/models.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _LiveNetwork extends HttpOverrides {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'deployed Iran Catalogue works through the real app API client',
() async {
// Explicit opt-in live test; only local preferences are mocked.
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues({});
await HttpOverrides.runWithHttpOverrides(() async {
final api = ApiService();
await api.setBaseUrl('https://reelbox.dartty.top/api/');
await api.initialize();
expect(api.baseUrl, 'https://reelbox.dartty.top/api/');
final home = await api.home();
final genres = await api.genres();
final countries = await api.countries();
final movies = await api.catalog();
final series = await api.catalog(kind: 'serie');
expect(home['genres'], isNotEmpty);
expect(genres, isNotEmpty);
expect(countries, isNotEmpty);
expect(movies, isNotEmpty);
expect(series, isNotEmpty);
TitleRecord? film;
for (final candidate in movies.take(5)) {
final detail = await api.poster(candidate.id);
if (detail.sources.isNotEmpty) {
film = detail;
break;
}
}
expect(film, isNotNull);
final seasons = await api.seasons(series.first.id);
expect(seasons, isNotEmpty);
final episodeSources = seasons
.expand((s) => s.episodes)
.expand((e) => e.sources);
expect(episodeSources, isNotEmpty);
final allSources = [...film!.sources, ...episodeSources];
for (final source in allSources) {
final uri = Uri.parse(source.url);
expect(uri.scheme, anyOf('http', 'https'));
expect(uri.host, isNot('reelbox.dartty.top'));
expect(uri.host, isNot('87.107.110.187'));
}
final search = await api.search('Matrix', english: true);
final recommendations = await api.bestRandom();
final curated = await api.curatedLinks();
expect(search, isNotEmpty);
expect(recommendations, isNotEmpty);
if (curated.isNotEmpty) {
expect(
await api.curatedContent(curated.first['link'] as String),
isNotEmpty,
);
}
expect(await api.comments(film.id), isEmpty);
final restored = ApiService();
await restored.initialize(discover: false);
expect(restored.baseUrl, api.baseUrl);
stdout.writeln(
jsonEncode({
'api_base': api.baseUrl,
'genre_count': genres.length,
'country_count': countries.length,
'movie_page_count': movies.length,
'series_page_count': series.length,
'movie_id': film.id,
'movie_sources': film.sources.length,
'series_id': series.first.id,
'season_count': seasons.length,
'episode_sources': episodeSources.length,
'search_count': search.length,
'recommendations': recommendations.length,
'curated_groups': curated.length,
'all_media_sources_direct': true,
'api_choice_restored': true,
'media_downloaded': false,
}),
);
}, _LiveNetwork());
},
timeout: const Timeout(Duration(minutes: 3)),
);
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Run real Android activity timing/EOF handlers with fixture events, without JNI.
Uses the existing lean Robolectric resolver/runner. This is control-flow evidence,
not decoded-media, network, or physical-device verification.
"""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import sys
import zipfile
import player_overlay_android_test as harness
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox'
WORK = Path('/root/.hermes/cache/scratch/reelbox-native-timing')
def aar_classes(artifact, version):
matches = sorted((harness.CACHE / artifact / version).glob('*/*.aar'))
if not matches:
raise RuntimeError(f'Missing cached AAR: {artifact}')
target = WORK / (matches[-1].stem + '-classes.jar')
with zipfile.ZipFile(matches[-1]) as archive:
target.write_bytes(archive.read('classes.jar'))
return target
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--test')
args = parser.parse_args()
WORK.mkdir(parents=True, exist_ok=True)
(WORK / 'tmp').mkdir(exist_ok=True)
harness.WORK = WORK
sdk = Path(os.environ.get('ANDROID_HOME', os.environ.get('ANDROID_SDK_ROOT', str(Path.home() / 'Android/Sdk'))))
android = sdk / 'platforms/android-36/android.jar'
if not android.is_file():
raise RuntimeError(f'Android 36 jar missing: {android}')
with (ROOT / '.tooling.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
harness.LOCK_FD = lock.fileno()
deps = harness.resolve()
stdlib = harness.cached_jar('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0')
vlc = aar_classes('org.videolan.android/libvlc-all', '3.7.7')
core = aar_classes('androidx.core/core', '1.16.0')
classes = WORK / 'classes'
classes.mkdir(exist_ok=True)
cp = os.pathsep.join(map(str, [classes, android, stdlib, vlc, core, *deps]))
compiler = [harness.cached_jar('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'), stdlib,
harness.cached_jar('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'),
harness.cached_jar('org.jetbrains.kotlin/kotlin-reflect'),
harness.cached_jar('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm'),
harness.cached_jar('org.jetbrains/annotations')]
compiler += sorted((harness.CACHE / 'org.jetbrains.intellij.deps/trove4j').glob('*/*/*.jar'))[-1:]
sources = [SRC / name for name in ('NativeUrlPolicy.kt', 'NativePlayerPolicy.kt', 'NativeFileIntents.kt',
'PlayerControlsState.kt', 'PlayerOverlayView.kt', 'VlcPlayerActivity.kt')]
test = ROOT / 'android/app/src/test/java/org/reelbox/reelbox/NativePlayerTimingAndroidTest.java'
hashes = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in [*sources, test]}
(WORK / 'tested-inputs.json').write_text(json.dumps(hashes, indent=2) + '\n')
harness.run([str(harness.JDK / 'bin/java'), '-Xmx256m', '-XX:+UseSerialGC', f'-Djava.io.tmpdir={WORK / "tmp"}',
'-cp', os.pathsep.join(map(str, compiler)), 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler',
'-jvm-target', '17', '-no-stdlib', '-no-reflect', '-classpath', cp,
'-d', str(classes), *map(str, sources)], log='kotlin-compile.log')
(WORK / 'OverlayHostRunner.java').write_text(harness.RUNNER)
(WORK / 'OverlayHarnessSmokeTest.java').write_text(harness.SMOKE)
harness.run([str(harness.JDK / 'bin/javac'), '-J-Xmx192m', '--release', '17', '-cp', cp, '-d', str(classes),
str(WORK / 'OverlayHostRunner.java'), str(WORK / 'OverlayHarnessSmokeTest.java'), str(test)], log='javac-timing.log')
opens = ['java.base/java.lang', 'java.base/java.util', 'java.base/java.io', 'java.base/java.net',
'java.base/java.security', 'java.base/java.text', 'java.base/jdk.internal.access',
'java.desktop/java.awt.font', 'jdk.compiler/com.sun.tools.javac.api']
java = [str(harness.JDK / 'bin/java'), '-Xmx320m', '-XX:MaxMetaspaceSize=192m', '-XX:+UseSerialGC',
f'-Djava.io.tmpdir={WORK / "tmp"}', '-Drobolectric.dependency.repo.url=https://repo.maven.apache.org/maven2',
f'-Dmaven.repo.local={WORK / "m2"}', '-Drobolectric.graphicsMode=LEGACY',
*['--add-opens=' + p + '=ALL-UNNAMED' for p in opens], '-cp', cp,
'org.reelbox.reelbox.OverlayHostRunner']
harness.run([*java, 'org.reelbox.reelbox.OverlayHarnessSmokeTest', str(WORK / 'smoke-results.xml')], log='smoke.log')
command = [*java, 'org.reelbox.reelbox.NativePlayerTimingAndroidTest', str(WORK / 'timing-results.xml')]
if args.test:
command.append(args.test)
harness.run(command, log='timing.log')
assert all(hashlib.sha256(Path(p).read_bytes()).hexdigest() == digest for p, digest in hashes.items())
print('PASS: actual activity event handlers and result gate; fixture events, no decoder/device claims')
if __name__ == '__main__':
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Offline, serialized JVM regressions; uses the existing Gradle Kotlin cache."""
import argparse
import fcntl
import glob
import os
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox'
TEST = ROOT / 'android/app/src/test/kotlin/org/reelbox/reelbox'
def jar(group, artifact, version):
matches = glob.glob(str(Path.home() / '.gradle/caches/modules-2/files-2.1' /
group / artifact / version / '*' / '*.jar'))
if len(matches) != 1:
raise RuntimeError(f'Expected one cached {artifact}:{version}, got {len(matches)}')
return matches[0]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--case', default='all')
parser.add_argument('--skip-integration', action='store_true')
args = parser.parse_args()
with (ROOT / '.tooling.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
helper = SRC / 'RestrictedResourceFetcher.kt'
assert helper.exists(), 'RM007: no controlled resource fetcher; WebView owns unchecked redirects'
version = '2.2.21'
stdlib = jar('org.jetbrains.kotlin', 'kotlin-stdlib', version)
annotations = jar('org.jetbrains', 'annotations', '13.0')
compiler = [jar('org.jetbrains.kotlin', item, version) for item in (
'kotlin-compiler-embeddable', 'kotlin-stdlib', 'kotlin-reflect',
'kotlin-script-runtime', 'kotlin-daemon-embeddable')]
compiler += [annotations, jar('org.jetbrains.kotlinx', 'kotlinx-coroutines-core-jvm', '1.8.0')]
with tempfile.TemporaryDirectory(prefix='native-resource-test-', dir=os.environ.get('TMPDIR')) as out:
sources = [SRC / 'NativeUrlPolicy.kt', SRC / 'PlaybackSnapshot.kt', helper,
TEST / 'NativePolicyTest.kt', TEST / 'RestrictedResourceFetchTest.kt']
subprocess.run(['java', '-Xmx256m', '-XX:MaxMetaspaceSize=192m', '-XX:+UseSerialGC',
'-cp', os.pathsep.join(compiler), 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler',
'-no-stdlib', '-no-reflect', '-jvm-target', '17',
'-classpath', os.pathsep.join([stdlib, annotations]), '-d', out,
*map(str, sources)], check=True, cwd=ROOT, timeout=120)
classpath = os.pathsep.join([out, stdlib])
subprocess.run(['java', '-Xmx128m', '-cp', classpath,
'org.reelbox.reelbox.tests.NativePolicyTestKt'], check=True, cwd=ROOT, timeout=60)
subprocess.run(['java', '-Xmx128m', '--add-modules', 'jdk.httpserver', '-cp', classpath,
'org.reelbox.reelbox.tests.RestrictedResourceFetchTestKt', args.case], check=True, cwd=ROOT, timeout=60)
if not args.skip_integration:
activity = (SRC / 'EmbeddedVideoActivity.kt').read_text()
start = activity.index('override fun shouldInterceptRequest(')
end = activity.index('override fun onReceivedSslError', start)
interceptor = activity[start:end]
assert 'return null' not in interceptor, 'RM007: interceptor delegates unchecked redirects to WebView'
assert 'resourceFetcher.fetch(' in interceptor, 'RM007: controlled fetcher is not wired to WebView'
assert interceptor.count('resourceFetcher.fetch(') == 2, 'Both WebView interceptor overloads must use controlled fetching'
assert 'request.method, request.requestHeaders' in interceptor, 'Method/range headers are not wired'
assert 'secureContext = !request.isForMainFrame && securePage' in interceptor, 'Mixed-content guard is missing'
assert 'handler.cancel()' in activity and 'handler.proceed()' not in activity, 'TLS errors must fail closed'
assert 'response.body.close()' in activity and 'catch (_: IllegalArgumentException)' in activity, 'Invalid Android response metadata must fail closed'
print('WebView interceptor source wiring: passed (not a device test)', flush=True)
if __name__ == '__main__':
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Run deterministic real Kotlin controls tests and optional Android SDK compilation.
Uses cached Kotlin jars, Android 36 and LibVLC 3.7.7; no downloads or Gradle.
All compiler invocations share the repository tooling lock.
"""
import argparse
import fcntl
import os
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox'
TEST = ROOT / 'android/app/src/test/kotlin/org/reelbox/reelbox'
CACHE = Path.home() / '.gradle/caches/modules-2/files-2.1'
SCRATCH = Path('/root/.hermes/cache/scratch')
def compiler_jars():
jars = []
for artifact, version in [
('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'),
('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0'),
('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'),
('org.jetbrains.kotlin/kotlin-reflect', '*'),
('org.jetbrains.intellij.deps/trove4j', '*'),
('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm', '*'),
('org.jetbrains/annotations', '*'),
]:
matches = sorted((CACHE / artifact).glob(version + '/*/*.jar'))
if matches:
jars.append(str(matches[-1]))
elif version != '*':
raise RuntimeError(f'Missing cached compiler dependency: {artifact}:{version}')
return jars
def compile_sources(jars, sources, classpath, out):
subprocess.run([
'java', '-Xmx320m', '-XX:MaxMetaspaceSize=192m', '-XX:+UseSerialGC',
f'-Djava.io.tmpdir={SCRATCH}', '-cp', os.pathsep.join(jars),
'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', '-jvm-target', '17',
'-no-stdlib', '-no-reflect', '-classpath', os.pathsep.join(classpath),
'-d', str(out), *map(str, sources),
], cwd=ROOT, check=True, timeout=180)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--native', action='store_true', help='Also compile actual native player sources')
args = parser.parse_args()
print('Waiting for repository tooling lock', flush=True)
with (ROOT / '.tooling.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
print('Repository tooling lock acquired', flush=True)
jars = compiler_jars()
with tempfile.TemporaryDirectory(prefix='player-controls-', dir=SCRATCH) as temp:
out = Path(temp) / 'state'
compile_sources(jars, [SRC / 'PlayerControlsState.kt', TEST / 'PlayerControlsStateTest.kt'], jars, out)
subprocess.run(['java', '-Xmx96m', '-cp', os.pathsep.join([str(out), *jars]),
'org.reelbox.reelbox.tests.PlayerControlsStateTestKt'],
check=True, cwd=ROOT, timeout=30)
if args.native:
deps = [
'/root/developer-tools/android/platforms/android-36/android.jar',
'/root/.hermes/cache/scratch/reelbox-vlc-api/classes.jar',
'/root/developer-tools/flutter/bin/cache/artifacts/engine/android-arm64-release/flutter.jar',
str(ROOT / 'build/app/tmp/kotlin-classes/release'),
]
deps += map(str, (Path.home() / '.gradle/caches/9.3.1/transforms').glob('*/transformed/core-1.16.0-api.jar'))
deps += map(str, (CACHE / 'androidx.lifecycle/lifecycle-common-jvm').glob('*/*/*.jar'))
sources = [SRC / name for name in [
'NativeUrlPolicy.kt', 'NativePlayerPolicy.kt', 'NativeFileIntents.kt',
'PlayerControlsState.kt', 'PlayerOverlayView.kt', 'VlcPlayerActivity.kt', 'MainActivity.kt',
]]
compile_sources(jars, sources, [*jars, *deps], Path(temp) / 'native')
print('Native player compilation PASS: actual Android 36 / LibVLC 3.7.7 SDKs', flush=True)
if __name__ == '__main__':
main()
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""Lean host Android-view tests, without the Android/Flutter Gradle build.
Run: python3 tool/player_overlay_android_test.py
--setup-only resolves reusable dependencies and exercises Android/Handler smoke tests.
--test METHOD runs one production-overlay method; all methods run by default.
All Gradle/compiler/JVM processes acquire the repository's .tooling.lock.
Outputs, dependency manifests and JUnit XML live in scratch, not the app build.
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import shlex
import subprocess
import sys
import time
import zipfile
ROOT = Path(__file__).resolve().parents[1]
WORK = Path('/root/.hermes/cache/scratch/reelbox-overlay-android-tests')
CACHE = Path.home() / '.gradle/caches/modules-2/files-2.1'
JDK = Path(os.environ.get('OVERLAY_TEST_JAVA_HOME', '/usr/lib/jvm/java-17-openjdk-amd64'))
ANDROID = Path(os.environ.get('OVERLAY_TEST_ANDROID_JAR', '/root/developer-tools/android/platforms/android-36/android.jar'))
LOCK = ROOT / '.tooling.lock'
LOCK_FD: int | None = None
BUILD = '''
import org.gradle.api.attributes.java.TargetJvmEnvironment
plugins { id 'base' }
repositories {
maven { url = uri('https://repo.maven.apache.org/maven2') }
google { content { includeGroupByRegex 'androidx[.].*' } }
}
configurations {
harness {
attributes {
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
objects.named(TargetJvmEnvironment, TargetJvmEnvironment.STANDARD_JVM))
}
}
}
dependencies {
harness('org.robolectric:robolectric:4.16.1') {
// LEGACY graphics never loads the large native renderer distribution.
exclude group: 'org.robolectric', module: 'nativeruntime-dist-compat'
}
harness 'junit:junit:4.13.2'
}
tasks.register('resolveHarness') {
doLast {
file('classpath.txt').text = configurations.harness.files.collect { it.absolutePath }.sort().join('\\n') + '\\n'
}
}
'''
SMOKE = '''
package org.reelbox.reelbox;
import android.app.Activity;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.Button;
import java.time.Duration;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.GraphicsMode;
import org.robolectric.Shadows;
import static org.junit.Assert.*;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 28, manifest = Config.NONE, qualifiers = "w360dp-h780dp-mdpi")
@LooperMode(LooperMode.Mode.PAUSED)
@GraphicsMode(GraphicsMode.Mode.LEGACY)
public class OverlayHarnessSmokeTest {
@Test public void realAndroidLayoutClickAndMainHandler() {
try (var controller = Robolectric.buildActivity(Activity.class).setup().visible()) {
Activity activity = controller.get();
FrameLayout root = new FrameLayout(activity);
Button button = new Button(activity);
root.addView(button, new FrameLayout.LayoutParams(120, 48));
activity.setContentView(root);
root.measure(View.MeasureSpec.makeMeasureSpec(360, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(780, View.MeasureSpec.EXACTLY));
root.layout(0, 0, 360, 780);
assertEquals(120, button.getWidth());
assertEquals(48, button.getHeight());
int[] clicks = {0};
button.setOnClickListener(v -> clicks[0]++);
assertTrue(button.performClick());
assertEquals(1, clicks[0]);
new Handler(Looper.getMainLooper()).postDelayed(() -> button.setVisibility(View.GONE), 3000);
Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2999));
assertEquals(View.VISIBLE, button.getVisibility());
Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1));
assertEquals(View.GONE, button.getVisibility());
}
}
}
'''
RUNNER = '''
package org.reelbox.reelbox;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.junit.runner.JUnitCore;
import org.junit.runner.Request;
import org.junit.runner.Result;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
public class OverlayHostRunner {
public static void main(String[] args) throws Exception {
JUnitCore core = new JUnitCore();
core.addListener(new RunListener() {
@Override public void testStarted(org.junit.runner.Description d) {
System.out.println("RUN " + d.getMethodName());
}
@Override public void testFailure(Failure f) {
System.err.println("FAIL " + f.getTestHeader() + "\\n" + f.getTrace());
}
});
Class<?> clazz = Class.forName(args[0]);
Result result = core.run(args.length > 2 ? Request.method(clazz, args[2]) : Request.aClass(clazz));
var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
var suite = doc.createElement("testsuite");
suite.setAttribute("name", args[0]);
suite.setAttribute("tests", Integer.toString(result.getRunCount()));
suite.setAttribute("failures", Integer.toString(result.getFailureCount()));
suite.setAttribute("skipped", Integer.toString(result.getIgnoreCount()));
suite.setAttribute("time", Double.toString(result.getRunTime() / 1000.0));
doc.appendChild(suite);
for (Failure failure : result.getFailures()) {
var test = doc.createElement("testcase");
test.setAttribute("name", failure.getTestHeader());
var fail = doc.createElement("failure");
fail.setAttribute("message", String.valueOf(failure.getMessage()));
fail.setTextContent(failure.getTrace());
test.appendChild(fail); suite.appendChild(test);
}
TransformerFactory.newInstance().newTransformer().transform(new DOMSource(doc), new StreamResult(new File(args[1])));
System.out.printf("RESULT tests=%d failures=%d ignored=%d elapsed_ms=%d%n", result.getRunCount(), result.getFailureCount(), result.getIgnoreCount(), result.getRunTime());
System.exit(result.wasSuccessful() && result.getRunCount() > 0 ? 0 : 1);
}
}
'''
def run(command: list[str], *, log: str | None = None) -> None:
env = dict(os.environ, JAVA_HOME=str(JDK), TMPDIR=str(WORK / 'tmp'))
env['PATH'] = str(JDK / 'bin') + os.pathsep + env.get('PATH', '')
display = command.copy()
for flag in ('-cp', '-classpath'):
if flag in display:
index = display.index(flag) + 1
display[index] = f'<{len(display[index].split(os.pathsep))} classpath entries>'
print('+', shlex.join(display), flush=True)
assert LOCK_FD is not None, 'Heavy invocations must hold the tooling flock'
with (WORK / 'commands.log').open('a') as command_log:
command_log.write(shlex.join(command) + '\n')
# Inherit the locked descriptor so a timed-out parent cannot unlock a live JVM.
proc = subprocess.Popen(command, cwd=WORK, env=env, pass_fds=(LOCK_FD,),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
assert proc.stdout is not None
log_path = WORK / (log or 'last-command.log')
if log_path.exists():
log_path.rename(log_path.with_name(log_path.name + '.' + str(time.time_ns())))
with log_path.open('w') as output:
for line in proc.stdout:
print(line, end='', flush=True)
output.write(line)
if proc.wait():
raise subprocess.CalledProcessError(proc.returncode, command)
def cached_jar(artifact: str, version: str = '*') -> Path:
paths = sorted((CACHE / artifact).glob(version + '/*/*.jar'))
if not paths:
raise RuntimeError(f'Missing cached compiler dependency: {artifact}:{version}')
return paths[-1]
def resolve() -> list[Path]:
build = WORK / 'build.gradle'
fingerprint = hashlib.sha256(BUILD.encode()).hexdigest()
stamp = WORK / 'dependency-fingerprint'
manifest = WORK / 'classpath.txt'
fresh = stamp.exists() and stamp.read_text() == fingerprint and manifest.exists()
if fresh:
fresh = all(Path(line).exists() for line in manifest.read_text().splitlines())
if not fresh:
build.write_text(BUILD)
(WORK / 'settings.gradle').write_text("rootProject.name = 'reelbox-overlay-host-tests'\n")
(WORK / 'gradle.properties').write_text('org.gradle.jvmargs=-Xmx256m -XX:MaxMetaspaceSize=192m\norg.gradle.workers.max=1\norg.gradle.daemon=false\norg.gradle.vfs.watch=false\n')
candidates = sorted((Path.home() / '.gradle/wrapper/dists').glob('gradle-9.3.1-bin/*/gradle-9.3.1/bin/gradle'))
gradle = os.environ.get('OVERLAY_TEST_GRADLE') or (str(candidates[0]) if candidates else shutil.which('gradle'))
if not gradle:
raise RuntimeError('No Gradle 9.3.1 distribution found; set OVERLAY_TEST_GRADLE')
run([gradle, '--no-daemon', '--max-workers=1', '--console=plain', 'resolveHarness'], log='dependency-resolution.log')
stamp.write_text(fingerprint)
jars = []
for line in manifest.read_text().splitlines():
path = Path(line)
if path.suffix == '.aar':
destination = WORK / 'aar-jars' / (path.stem + '-classes.jar')
destination.parent.mkdir(exist_ok=True)
if not destination.exists():
with zipfile.ZipFile(path) as aar:
destination.write_bytes(aar.read('classes.jar'))
jars.append(destination)
else:
jars.append(path)
return jars
def main() -> int:
global LOCK_FD
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--setup-only', action='store_true')
parser.add_argument('--test', help='Run one PlayerOverlayAndroidTest method')
args = parser.parse_args()
WORK.mkdir(parents=True, exist_ok=True)
(WORK / 'tmp').mkdir(exist_ok=True)
# Serialize the complete small pipeline, not separate JVMs that let a full
# app verification jump into the middle of compilation and test execution.
print(f'Acquiring flock {LOCK}', flush=True)
lock_file = LOCK.open('a')
fcntl.flock(lock_file, fcntl.LOCK_EX)
LOCK_FD = lock_file.fileno()
if not (JDK / 'bin/java').is_file() or not ANDROID.is_file():
raise RuntimeError('JDK17 or Android compile jar missing; see environment overrides in script')
deps = resolve()
stdlib = cached_jar('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0')
sources = WORK / 'sources'
sources.mkdir(exist_ok=True)
(sources / 'OverlayHarnessSmokeTest.java').write_text(SMOKE)
(sources / 'OverlayHostRunner.java').write_text(RUNNER)
classes = WORK / 'classes'
if classes.exists():
shutil.rmtree(classes)
classes.mkdir()
cp = os.pathsep.join(map(str, [classes, ANDROID, stdlib, *deps]))
javac = [str(JDK / 'bin/javac'), '-J-Xmx192m', '--release', '17', '-cp', cp, '-d', str(classes)]
run([*javac, str(sources / 'OverlayHarnessSmokeTest.java'), str(sources / 'OverlayHostRunner.java')], log='javac-smoke.log')
opens = ['java.base/java.lang', 'java.base/java.util', 'java.base/java.io',
'java.base/java.net', 'java.base/java.security', 'java.base/java.text',
'java.base/jdk.internal.access', 'java.desktop/java.awt.font',
'jdk.compiler/com.sun.tools.javac.api']
java = [str(JDK / 'bin/java'), '-Xmx384m', '-XX:MaxMetaspaceSize=192m',
f'-Djava.io.tmpdir={WORK / "tmp"}',
'-Drobolectric.dependency.repo.url=https://repo.maven.apache.org/maven2',
f'-Dmaven.repo.local={WORK / "m2"}',
'-Drobolectric.graphicsMode=LEGACY',
*['--add-opens=' + item + '=ALL-UNNAMED' for item in opens],
'-cp', cp, 'org.reelbox.reelbox.OverlayHostRunner']
# The SDK resolver downloads only the requested SDK28 instrumented Android jar.
# A separate smoke run establishes that infrastructure errors aren't overlay failures.
run([*java, 'org.reelbox.reelbox.OverlayHarnessSmokeTest', str(WORK / 'smoke-results.xml')], log='smoke.log')
if args.setup_only:
print('SETUP VERIFIED: SDK28 / Java17 / Robolectric4.16.1 / PAUSED / LEGACY. No production-overlay tests requested.')
return 0
source = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox/PlayerOverlayView.kt'
if not source.exists():
print(f'BLOCKED: production overlay does not exist yet: {source}', file=sys.stderr)
return 2
compiler = [cached_jar('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'), stdlib,
cached_jar('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'),
cached_jar('org.jetbrains.kotlin/kotlin-reflect'),
cached_jar('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm'),
cached_jar('org.jetbrains/annotations')]
trove = sorted((CACHE / 'org.jetbrains.intellij.deps/trove4j').glob('*/*/*.jar'))
compiler += trove[-1:]
# Compile real production source, never a replacement or copied overlay implementation.
overlay_sources = [source, source.with_name('PlayerControlsState.kt')]
state_source = source.with_name('PlayerOverlayState.kt')
if state_source.exists():
overlay_sources.append(state_source)
test = ROOT / 'android/app/src/test/java/org/reelbox/reelbox/PlayerOverlayAndroidTest.java'
input_hashes = {str(path): hashlib.sha256(path.read_bytes()).hexdigest()
for path in [*overlay_sources, test]}
(WORK / 'tested-inputs.json').write_text(json.dumps({
'robolectric': '4.16.1', 'runtime_sdk': 28, 'java': 17,
'looper': 'PAUSED', 'graphics': 'LEGACY', 'sha256': input_hashes,
}, indent=2) + '\n')
run([str(JDK / 'bin/java'), '-Xmx256m', f'-Djava.io.tmpdir={WORK / "tmp"}', '-cp', os.pathsep.join(map(str, compiler)),
'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', '-jvm-target', '17', '-no-stdlib', '-no-reflect',
'-classpath', cp, '-d', str(classes), *map(str, overlay_sources)], log='kotlin-compile.log')
run([*javac, str(test)], log='javac-overlay.log')
command = [*java, 'org.reelbox.reelbox.PlayerOverlayAndroidTest', str(WORK / 'overlay-results.xml')]
if args.test:
command.append(args.test)
run(command, log='overlay.log')
for path, digest in input_hashes.items():
if hashlib.sha256(Path(path).read_bytes()).hexdigest() != digest:
raise RuntimeError(f'Source changed during test execution; rerun before claiming verification: {path}')
print('Verified real Android view events, Handler timing and layout on host LEGACY graphics; not a device pixel/rendering test.')
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except subprocess.CalledProcessError as error:
print(f'FAILED: command exited {error.returncode}; logs: {WORK}', file=sys.stderr)
sys.exit(error.returncode)
except (OSError, RuntimeError) as error:
print(f'BLOCKED: {error}', file=sys.stderr)
sys.exit(2)
+21
View File
@@ -0,0 +1,21 @@
"""Release identity verification must accept only the explicitly pinned signer."""
import unittest
from verify_apk import verified_signer
SHA = 'd684537f3637fde36c9e7b065b5acd78499c02d345c34bf87857b13ed1d8b87d'
class SignerTests(unittest.TestCase):
def test_authorized_replacement_key_has_its_actual_subject(self):
text = f'Signer #1 certificate DN: CN=Reelbox Release, OU=Reelbox\nSigner #1 certificate SHA-256 digest: {SHA}\n'
self.assertEqual(verified_signer(text, SHA), (SHA, 'CN=Reelbox Release, OU=Reelbox'))
def test_mismatched_or_debug_signer_is_rejected(self):
with self.assertRaises(AssertionError):
verified_signer(f'Signer #1 certificate DN: CN=Reelbox Release\nSigner #1 certificate SHA-256 digest: {SHA}', 'a' * 64)
with self.assertRaises(AssertionError):
verified_signer(f'Signer #1 certificate DN: CN=Android Debug\nSigner #1 certificate SHA-256 digest: {SHA}', SHA)
if __name__ == '__main__':
unittest.main()
+25
View File
@@ -0,0 +1,25 @@
"""A stale delivered APK must not pass the current-release gate."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
class ReleaseVersionTests(unittest.TestCase):
def test_previous_apk_is_rejected_for_current_source_version(self):
root = Path(__file__).resolve().parents[1]
old = root.parent / 'reelbox-delivery/reelbox-0.2.0-arm64.apk'
if not old.exists():
self.skipTest('Previous delivery not available on this verification host')
with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as temp:
result = subprocess.run([sys.executable, str(root / 'tool/verify_apk.py'),
str(old), '--output-dir', temp],
capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0, 'Verifier accepted the stale 0.2.0 APK for 0.3.0 source')
self.assertIn('Wrong package/version', result.stderr)
if __name__ == '__main__':
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Regression checks for the public download-page copy."""
from html.parser import HTMLParser
from pathlib import Path
import unittest
class Page(HTMLParser):
def __init__(self, text):
super().__init__()
self.links = []
self.visible = []
self.header = False
self.header_links = []
self.feed(text)
def handle_starttag(self, tag, attrs):
if tag == 'header':
self.header = True
if tag == 'a':
href = dict(attrs).get('href', '')
self.links.append(href)
if self.header:
self.header_links.append(href)
def handle_endtag(self, tag):
if tag == 'header':
self.header = False
def handle_data(self, data):
self.visible.append(data)
class WebsiteCopyTests(unittest.TestCase):
def setUp(self):
self.page = Page((Path(__file__).resolve().parents[1] / 'website/index.html').read_text())
def test_header_has_no_github_source_link(self):
self.assertFalse(any('github.com' in href for href in self.page.header_links))
self.assertNotIn('Source on GitHub', ''.join(self.page.visible))
def test_release_has_no_upgrade_comment(self):
self.assertNotIn('Updates v0.4.2 without uninstalling', ''.join(self.page.visible))
def test_footer_has_no_provider_disclaimer(self):
self.assertNotIn(
'External catalog and media providers. Use only media you’re entitled to access.',
''.join(self.page.visible),
)
def test_version_download_and_source_archive_remain(self):
self.assertIn('v0.4.4', ''.join(self.page.visible))
self.assertIn('/downloads/reelbox-0.4.4-arm64.apk', self.page.links)
self.assertIn('/downloads/reelbox-0.4.4-source.zip', self.page.links)
self.assertIn('Source archive', ''.join(self.page.visible))
if __name__ == '__main__':
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
// Optional host-rendered UI evidence; not Android device verification.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/app.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/library.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _CaptureApi extends ApiService {
@override
Future<Map<String, dynamic>> home() async => {
'genres': [],
'actors': [],
'countries': [],
};
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => [];
@override
Future<List<Tag>> countries() async => [];
@override
Future<List<Tag>> genres() async => [];
@override
Future<List<String>> trending() async => [];
@override
Future<List<TitleRecord>> bestRandom({
bool byYear = false,
int page = 1,
}) async => [];
}
void main() {
testWidgets(
'mobile local-library and settings UI renders in English and Persian',
(tester) async {
await tester.runAsync(() async {
final font = File(
Platform.environment['REELBOX_TEST_FONT'] ??
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
);
final bytes = await font.readAsBytes();
await (FontLoader(
'Roboto',
)..addFont(Future.value(bytes.buffer.asByteData()))).load();
await (FontLoader(
'Ahem',
)..addFont(Future.value(bytes.buffer.asByteData()))).load();
final icons = await File(
Platform.environment['REELBOX_ICON_FONT'] ?? '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf',
).readAsBytes();
await (FontLoader(
'MaterialIcons',
)..addFont(Future.value(icons.buffer.asByteData()))).load();
});
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(390, 844);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
// Explicitly invoked test harness; kept outside default test discovery.
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: _CaptureApi(),
storage: await SharedPreferences.getInstance(),
);
await state.initialize(discover: false);
final boundaryKey = GlobalKey();
await tester.pumpWidget(
RepaintBoundary(
key: boundaryKey,
child: MyApp(state: state),
),
);
await tester.pumpAndSettle();
final output = Directory(
Platform.environment['REELBOX_SCREENSHOT_DIR'] ??
'${Directory.current.path}/build/ui-verification',
);
await tester.runAsync(() => output.create(recursive: true));
Future<void> capture(String name) async {
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final boundary =
boundaryKey.currentContext!.findRenderObject()!
as RenderRepaintBoundary;
await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: 2);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
await File('${output.path}/$name.png')
.writeAsBytes(data!.buffer.asUint8List());
image.dispose();
});
}
expect(find.byType(FirstRunIntroPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
await capture('intro-en');
await state.updateSettings(state.prefs.copyWith(locale: 'fa'));
await tester.pumpAndSettle();
expect(find.byType(FirstRunIntroPage), findsOneWidget);
await capture('intro-fa');
await state.updateSettings(
state.prefs.copyWith(locale: 'en', introCompleted: true),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Library').last);
expect(find.byType(NavigationBar), findsOneWidget);
await capture('library-en');
await tester.tap(find.text('More').last);
await capture('more-en');
await tester.tap(find.text('Settings'));
await capture('settings-en');
await state.updateSettings(state.prefs.copyWith(locale: 'fa'));
await capture('settings-fa');
await state.updateSettings(state.prefs.copyWith(locale: 'en'));
await tester.drag(find.byType(ListView), const Offset(0, -10000));
expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing);
await capture('settings-data-en');
await state.updateSettings(state.prefs.copyWith(locale: 'fa'));
await capture('settings-data-fa');
await tester.pumpWidget(const SizedBox());
state.dispose();
},
);
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Verify Reelbox's signed ARM64 release APK without reading signing secrets."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import zipfile
def digest(path):
value = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
value.update(chunk)
return value.hexdigest()
def verified_signer(signature, expected_sha256=None):
signer = re.search(r'Signer #1 certificate SHA-256 digest: ([0-9a-fA-F]{64})\b', signature)
subject = re.search(r'^Signer #1 certificate DN: (.+)$', signature, re.M)
assert signer and subject, 'No release certificate identity'
assert 'Android Debug' not in subject.group(1), 'Debug certificate is not a release signer'
fingerprint = signer.group(1).lower()
if expected_sha256:
assert fingerprint == expected_sha256.lower(), 'Wrong release certificate fingerprint'
return fingerprint, subject.group(1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('apk', type=Path)
parser.add_argument('--android-sdk', type=Path, default=Path(os.environ.get('ANDROID_HOME', '/root/developer-tools/android')))
parser.add_argument('--build-tools', default='36.0.0')
parser.add_argument('--baseline-apk', type=Path)
parser.add_argument('--expected-signer-sha256', help='Pin the authorized release certificate, including a replacement key')
parser.add_argument('--output-dir', type=Path, required=True)
args = parser.parse_args()
source_version = re.search(r'^version:\s*([^+\s]+)\+(\d+)\s*$',
(Path(__file__).resolve().parents[1] / 'pubspec.yaml').read_text(), re.M)
assert source_version, 'Source version must include an explicit release name and code'
expected_name, expected_code = source_version.groups()
args.output_dir.mkdir(parents=True, exist_ok=True)
tools = args.android_sdk / 'build-tools' / args.build_tools
def run(tool, *arguments):
result = subprocess.run([str(tools / tool), *map(str, arguments)], capture_output=True, text=True, check=True)
return result.stdout + result.stderr
badging = run('aapt', 'dump', 'badging', args.apk)
signature = run('apksigner', 'verify', '--verbose', '--print-certs', args.apk)
(args.output_dir / 'apk-badging.log').write_text(badging)
(args.output_dir / 'apk-signature.log').write_text(signature)
package = re.search(r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'", badging)
assert package and package.groups() == ('org.reelbox.app', expected_code, expected_name), 'Wrong package/version; never deliver a stale APK'
assert 'application-debuggable' not in badging, 'Debuggable APK'
minimum = re.search(r"^sdkVersion:'(\d+)'", badging, re.M)
target = re.search(r"^targetSdkVersion:'(\d+)'", badging, re.M)
assert minimum and minimum.group(1) == '24'
assert target and target.group(1) == '36'
signer_sha256, signer_subject = verified_signer(signature, args.expected_signer_sha256)
with zipfile.ZipFile(args.apk) as archive:
assert archive.testzip() is None, 'APK ZIP corruption'
names = archive.namelist()
abis = sorted({name.split('/')[1] for name in names if name.startswith('lib/') and name.endswith('.so')})
assert abis == ['arm64-v8a'], 'Unsupported or dependency-only ABI advertised'
for library in ['libapp.so', 'libflutter.so', 'libmpv.so']:
assert 'lib/arm64-v8a/' + library in names, 'Missing ' + library
assert 'assets/flutter_assets/kernel_blob.bin' not in names, 'Debug kernel found'
baseline_matches = None
if args.baseline_apk:
baseline = run('apksigner', 'verify', '--print-certs', args.baseline_apk)
old = re.search(r'Signer #1 certificate SHA-256 digest: ([0-9a-fA-F]+)', baseline)
assert old and old.group(1).lower() == signer_sha256, 'Cannot upgrade the delivered beta: signer mismatch'
baseline_matches = True
report = {
'passed': True, 'apk': str(args.apk.resolve()), 'bytes': args.apk.stat().st_size,
'sha256': digest(args.apk), 'package': package.group(1), 'version_code': int(package.group(2)),
'version_name': package.group(3), 'min_sdk': 24, 'target_sdk': 36, 'abis': abis,
'release_signer': signer_subject, 'signer_sha256': signer_sha256,
'same_signer_as_delivered_beta': baseline_matches, 'zip_integrity': True,
'core_native_libraries_present': True, 'debuggable': False,
'android_hardware_verified': False,
}
(args.output_dir / 'apk-verification.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))
if __name__ == '__main__':
main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Run all Flutter test files, including real-worker host registration.
The multipart file needs a Linux path-provider registrant in spawned isolates.
Run it in its own invocation, not by skipping its cases in the normal suite.
All Flutter tooling shares the project's existing lock. This is host evidence,
not Android hardware or process-kill/restart verification.
"""
from __future__ import annotations
import argparse
import fcntl
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--flutter', default=shutil.which('flutter'))
parser.add_argument('--output-dir', type=Path)
args = parser.parse_args()
if not args.flutter:
parser.error('Flutter was not found; provide --flutter /path/to/flutter')
root = Path(__file__).resolve().parents[1]
output = (args.output_dir or root / 'build' / 'host-verification').resolve()
output.mkdir(parents=True, exist_ok=True)
files = sorted(p.relative_to(root).as_posix() for p in (root / 'test').rglob('*_test.dart'))
multipart = 'test/download_multipart_http_test.dart'
if multipart not in files:
parser.error('Required real-worker multipart test file is missing')
ordinary = [p for p in files if p != multipart]
common = [args.flutter, 'test', '--no-pub', '--concurrency=1', '--reporter=expanded', '--timeout=90s']
stages = [
('analysis', [args.flutter, 'analyze'], []),
('ordinary', common + ordinary, ordinary),
('multipart', common + ['--dart-define=flutter.dart_plugin_registrant=' + (root / multipart).as_uri(), multipart], [multipart]),
]
report = {
'scope': 'all Flutter test files; real multipart HTTP/workers run separately with required host registration',
'android_hardware_verified': False,
'expected_test_files': files,
'covered_test_files': [],
'stages': [],
'passed': False,
}
result = 0
with (root / '.tooling.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
for name, command, covered in stages:
log = output / (name + '.log')
print('Running ' + name + ' -> ' + str(log), flush=True)
try:
with log.open('w') as stream:
code = subprocess.run(command, cwd=root, stdout=stream, stderr=subprocess.STDOUT, timeout=1200, check=False).returncode
except subprocess.TimeoutExpired:
code = 124
text = log.read_text(errors='replace')
counts = re.findall(r'\+(\d+)(?: -\d+)?: All tests passed!', text)
stage = {'name': name, 'command': command, 'exit_code': code, 'log': str(log)}
if covered:
stage['passed_test_count'] = int(counts[-1]) if counts else None
if code == 0 and not counts:
code = 1
stage['exit_code'] = code
stage['verification_error'] = 'Missing successful nonempty test summary'
report['stages'].append(stage)
if code == 0:
report['covered_test_files'].extend(covered)
else:
result = code
break
report['passed'] = result == 0 and sorted(report['covered_test_files']) == files
if report['passed']:
report['passed_test_count'] = sum(stage.get('passed_test_count', 0) for stage in report['stages'])
(output / 'verification.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({key: report[key] for key in ['passed', 'expected_test_files', 'covered_test_files']}), flush=True)
print('Verification report: ' + str(output / 'verification.json'), flush=True)
return 0 if report['passed'] else (result or 1)
if __name__ == '__main__':
sys.exit(main())