[verified] Publish ReelBox source under GPLv3
This commit is contained in:
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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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'));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user