diff --git a/.gitignore b/.gitignore index eebdda6..4c20a92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .DS_Store __pycache__/ apk-catalog/data/cache/ -apk-catalog/data/index-v2.json* +apk-catalog/data/index-v2*.json* compat-db/.env.lakebed.server compat-db/.lakebed/ -apk-catalog/data/index-v2.archive.json* diff --git a/docs/apk-versions-measurement.txt b/docs/apk-versions-measurement.txt deleted file mode 100644 index 6976f2d..0000000 --- a/docs/apk-versions-measurement.txt +++ /dev/null @@ -1,24 +0,0 @@ -cold_seconds 33.303869292 -fdroid {"package": "org.fdroid.fdroid", "versions": [{"version": "2.0.0", "version_code": 2000050, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000050.apk", "sha256": "94938d324b03755fb324f143229240c75640e809cdd3a90cc0a545b5d0a61931", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000050.apk", "source": "F-Droid"}, {"version": "2.0-rc1", "version_code": 2000041, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000041.apk", "sha256": "36b343514ea2cdd353b7554c14b3da3f4309653b67be5ceb2e912b25597e3d85", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000041.apk", "source": "F-Droid"}, {"version": "2.0-rc0", "version_code": 2000040, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000040.apk", "sha256": "cd492ba2d5a425ad80aa22f4b13d9815b2ad487e9b2b6df6b67c31b957b19f2b", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000040.apk", "source": "F-Droid"}, {"version": "2.0-alpha11", "version_code": 2000011, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000011.apk", "sha256": "730f81f2cf860b2671410e3bb7a9f00f99ebd8020aea941c316db1f55cd030e3", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000011.apk", "source": "F-Droid"}, {"version": "2.0-alpha10", "version_code": 2000010, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000010.apk", "sha256": "0b6c65becb0712828625976c6baa76fc40153f77871487b5bc231c3433856b50", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000010.apk", "source": "F-Droid"}, {"version": "2.0-alpha9", "version_code": 2000009, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000009.apk", "sha256": "d6e5eaea63107aed8e46a1c2de3db4c878dcafccb832ec7048798c1275b0a1b7", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000009.apk", "source": "F-Droid"}, {"version": "2.0-alpha8", "version_code": 2000008, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000008.apk", "sha256": "72e139a04fcd0f587a85de5b118245f1cbcf2939874a4f3e298ae6eaea98653c", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000008.apk", "source": "F-Droid"}, {"version": "2.0-alpha7", "version_code": 2000007, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/org.fdroid.fdroid_2000007.apk", "sha256": "2acb72be96ae91342d52822c1b100edc60c5240eea630e4fc59cb7c4e18d7fbf", "url": "https://f-droid.org/repo/org.fdroid.fdroid_2000007.apk", "source": "F-Droid"}], "total": 250, "links": [{"source": "APKMirror", "url": "https://www.apkmirror.com/?post_type=app_release&s=org.fdroid.fdroid"}, {"source": "APKPure", "url": "https://apkpure.com/search?q=org.fdroid.fdroid"}, {"source": "Uptodown", "url": "https://en.uptodown.com/android/search/org.fdroid.fdroid"}, {"source": "F-Droid", "url": "https://search.f-droid.org/?q=org.fdroid.fdroid"}, {"source": "GitHub", "url": "https://github.com/search?type=repositories&q=org.fdroid.fdroid"}], "note": "Pick a version whose minimum is Android 11 or lower and that has an arm64-v8a build (or no native code). Installable does not mean every feature works.", "errors": []} -termux_warm_seconds 0.00022699999999531428 -termux {"package": "com.termux", "versions": [{"version": "0.119.0-beta.3", "version_code": 1022, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_1022.apk", "sha256": "fdd476982cd74f2f00aac12d3683b1fa260a0b2d146411b94e09d773be3a7b56", "url": "https://f-droid.org/repo/com.termux_1022.apk", "source": "F-Droid"}, {"version": "0.119.0-beta.2", "version_code": 1021, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_1021.apk", "sha256": "54f472928acf98dd58ba4a1444cac41a4f144e015696ffe1fd81f13a23b997d0", "url": "https://f-droid.org/repo/com.termux_1021.apk", "source": "F-Droid"}, {"version": "0.118.3", "version_code": 1002, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_1002.apk", "sha256": "e6265a57eb5ca363808488e3b01955958bed93bc0c8a0d281849b363b11027ec", "url": "https://f-droid.org/repo/com.termux_1002.apk", "source": "F-Droid"}, {"version": "0.118.2", "version_code": 1001, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_1001.apk", "sha256": "75a8dd74d6fe69681af450869d872cc6a7ce13dc6ef5f0d9c1b94ed9cc734d50", "url": "https://f-droid.org/archive/com.termux_1001.apk", "source": "F-Droid archive"}, {"version": "0.118.1", "version_code": 1000, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_1000.apk", "sha256": "f137958392a800fca583bfc00f191b8edb29b77c705fddf27dffb6c26ca5d413", "url": "https://f-droid.org/archive/com.termux_1000.apk", "source": "F-Droid archive"}, {"version": "0.118.0", "version_code": 118, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_118.apk", "sha256": "822ac152bd7c2d9770b87c1feea03f22f2349a91b94481b268c739493a260f0b", "url": "https://f-droid.org/archive/com.termux_118.apk", "source": "F-Droid archive"}, {"version": "0.117", "version_code": 117, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_117.apk", "sha256": "956f5945dd616206b1f226c54b1908110a1e8e2e759ba71c6f888577fa739993", "url": "https://f-droid.org/archive/com.termux_117.apk", "source": "F-Droid archive"}, {"version": "0.116", "version_code": 116, "min_sdk": 24, "abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"], "name": "/com.termux_116.apk", "sha256": "fccc12892e3cfda140d23aaefb668e20934231ee5364d7ca8810b8e4ca0d178e", "url": "https://f-droid.org/archive/com.termux_116.apk", "source": "F-Droid archive"}], "total": 71, "links": [{"source": "APKMirror", "url": "https://www.apkmirror.com/?post_type=app_release&s=com.termux"}, {"source": "APKPure", "url": "https://apkpure.com/search?q=com.termux"}, {"source": "Uptodown", "url": "https://en.uptodown.com/android/search/com.termux"}, {"source": "F-Droid", "url": "https://search.f-droid.org/?q=com.termux"}, {"source": "GitHub", "url": "https://github.com/search?type=repositories&q=com.termux"}], "note": "Pick a version whose minimum is Android 11 or lower and that has an arm64-v8a build (or no native code). Installable does not mean every feature works.", "errors": []} -warm_mean_seconds 0.000251964583000003 -cache_bytes 16460680 - 33.97 real 5.68 user 0.64 sys - 105906176 maximum resident set size - 0 average shared memory size - 0 average unshared data size - 0 average unshared stack size - 25234 page reclaims - 24 page faults - 0 swaps - 0 block input operations - 0 block output operations - 31 messages sent - 24566 messages received - 0 signals received - 259 voluntary context switches - 22262 involuntary context switches - 52738979493 instructions retired - 18814737630 cycles elapsed - 117670368 peak memory footprint diff --git a/docs/apks.md b/docs/apks.md index 4cb17b5..8bca60a 100644 --- a/docs/apks.md +++ b/docs/apks.md @@ -49,11 +49,12 @@ needs to be confirmed or started in the headset. ## When an app needs a newer Android Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses -an APK, it shows compatible versions from F-Droid's main and archive repos. -The refusal appears immediately; a separate request checks F-Droid while the -dialog shows “Checking F-Droid for older versions…”. It shows at most eight -distinct version names, newest first, preferring an arm64-only build when -available. The count includes all compatible builds before release deduplication. +an APK, it shows compatible versions from F-Droid's main and archive repos and +IzzyOnDroid. It shows at most eight version names, newest first, preferring an +arm64-only build, and says how many compatible builds it found in total. +Each index is reduced to its compatible builds once a day and cached (about +16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are +instant. Choose **Install** to download a listed version, verify its SHA-256 against the index, and install it as its own app. @@ -324,51 +325,3 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified whether the settings survive the app or its Lepton instance relaunching. Lepton Development rebuilds its Android data on exit, so there they probably don't. - -## Version lookup performance - -Measured on 2026-09-28 with Python 3.9.6 against live f-droid.org, using an -empty temporary cache. Both repositories download sequentially under one lock. -The reducer decodes one package at a time with the standard library; the -`.installable-v1` files contain only compatible build records grouped by package. -Raw downloads are removed after reduction. The compact files refresh after one -day and are retained in memory by file mtime. Install uses the cached records -without refreshing the indexes or rerunning release selection. - -- Cold lookup: 33.304 s (process wall time 33.97 s). -- Maximum RSS: 105,906,176 bytes (101 MiB). -- Warm F-Droid lookup: 0.252 ms mean over 1,000 calls. -- Warm Termux lookup: 0.227 ms. -- Combined reduced cache: 16,460,680 bytes (15.7 MiB). -- F-Droid: 8 of 250 compatible builds, newest 2.0.0 (code 2000050). -- Termux: 8 of 71 compatible builds, newest 0.119.0-beta.3 (code 1022). -- Both repository lookups returned no errors. - -Full results and `/usr/bin/time -l` output: [measurement](apk-versions-measurement.txt). -Network timings depend on the connection and repository state. Reproduce with: - -```sh -/usr/bin/time -l python3 -c ' -import json, sys, tempfile, time -sys.path.insert(0, "ui") -import frame_catalog as c, frame_apk_versions as v -with tempfile.TemporaryDirectory() as directory: - c.CATALOG = directory - start = time.perf_counter() - print(json.dumps(v.alternatives("org.fdroid.fdroid"))) - print("cold_seconds", time.perf_counter() - start) - print(json.dumps(v.alternatives("com.termux"))) - start = time.perf_counter() - for _ in range(1000): - v.alternatives("org.fdroid.fdroid") - print("warm_mean_seconds", (time.perf_counter() - start) / 1000) -' -``` - -Verification: `python3 -m unittest discover -s tests` passed 138 tests; -`python3 -m py_compile ui/*.py`, inline JavaScript `node --check`, and -`git diff --check` passed. Tests include concurrent cache use, failed refresh, -mtime invalidation, streaming boundaries, cap/deduplication, endpoint validation, -nonblocking refusal, and cached installation identity checks. No browser visual -verification or physical headset installation was performed. Independent model -review was omitted to honor the task's explicit no-delegation instruction. diff --git a/tests/test_frame_apk_versions.py b/tests/test_frame_apk_versions.py index 8e3c050..5d76c64 100644 --- a/tests/test_frame_apk_versions.py +++ b/tests/test_frame_apk_versions.py @@ -28,7 +28,8 @@ class VersionsTest(unittest.TestCase): os.mkdir(data) for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']), build(3, abis=['arm64-v8a', 'x86_64']), build(2)]), - ('index-v2.archive.json', [build(1, 21), build(2)])]: + ('index-v2.archive.json', [build(1, 21), build(2)]), + ('index-v2.izzy.json', [])]: with open(os.path.join(data, name), 'w') as f: json.dump({'packages': {'org.example.app': {'versions': {str(i): b for i, b in enumerate(builds)}}}}, f) self.enter_patch(patch.object(frame_catalog, 'CATALOG', self.tmp.name)) @@ -63,7 +64,7 @@ class VersionsTest(unittest.TestCase): def test_failed_indexes_keep_search_links(self): with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')): result = versions.alternatives('org.example.app') - self.assertEqual(len(result['errors']), 2) + self.assertEqual(len(result['errors']), 3) self.assertEqual(len(result['links']), 5) def test_no_compatible_versions(self): @@ -79,7 +80,8 @@ class VersionsTest(unittest.TestCase): self.assertEqual(set(index['org.example.app'][0]), {'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'}) raw = os.path.join(self.tmp.name, 'data', 'index-v2.json') - self.assertFalse(os.path.exists(raw)) + self.assertTrue(os.path.exists(raw)) # the catalogue build reads it + os.utime(raw, ns=(1, 1)) with patch.object(frame_catalog.json, 'load', side_effect=AssertionError('reparsed')): self.assertIs(frame_catalog.load_index(repo), index) path = raw + '.installable-v1' @@ -91,7 +93,7 @@ class VersionsTest(unittest.TestCase): with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(payload)) as fetch: self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 9) fetch.assert_called_once() - self.assertFalse(os.path.exists(raw)) + self.assertTrue(os.path.exists(raw)) def test_concurrent_requests_share_download(self): raw = os.path.join(self.tmp.name, 'data', 'index-v2.json') @@ -108,6 +110,7 @@ class VersionsTest(unittest.TestCase): index = frame_catalog.load_index(repo) path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1') os.utime(path, ns=(1, 1)) + os.utime(os.path.join(self.tmp.name, 'data', 'index-v2.json'), ns=(1, 1)) with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(b'{')): with self.assertRaises(ValueError): frame_catalog.load_index(repo) diff --git a/ui/frame_apk_versions.py b/ui/frame_apk_versions.py index e69d3e5..81f3102 100644 --- a/ui/frame_apk_versions.py +++ b/ui/frame_apk_versions.py @@ -11,7 +11,8 @@ ANDROID = dict(enumerate([ '12L', '13', '14', '15', '16', ], 1)) REPOS = (('F-Droid', 'https://f-droid.org/repo/'), - ('F-Droid archive', 'https://f-droid.org/archive/')) + ('F-Droid archive', 'https://f-droid.org/archive/'), + ('IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/')) NOTE = ('Pick a version whose minimum is Android 11 or lower and that has an ' 'arm64-v8a build (or no native code). Installable does not mean every feature works.') diff --git a/ui/frame_catalog.py b/ui/frame_catalog.py index 17ddd83..8a99513 100644 --- a/ui/frame_catalog.py +++ b/ui/frame_catalog.py @@ -17,7 +17,11 @@ import frame_compat_db as compat_db # noqa: E402 # the per-user cache (FRAME_CONTROL_APP is set by app/main.js). CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG else os.path.join(CATALOG, 'data', 'cache')) -APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/') +# Repo base URL -> local name of its index; every APK download must come from one of these. +INDEX_FILES = {'https://f-droid.org/repo/': 'index-v2.json', + 'https://f-droid.org/archive/': 'index-v2.archive.json', + 'https://apt.izzysoft.de/fdroid/repo/': 'index-v2.izzy.json'} +APK_HOSTS = tuple(INDEX_FILES) _lock = threading.Lock() _cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None} _env = {} @@ -112,7 +116,7 @@ def load_index(repo, cached_only=False): if repo not in APK_HOSTS: raise ValueError('unexpected index URL') directory = CACHE if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG else os.path.join(CATALOG, 'data') - filename = 'index-v2.json' if repo == APK_HOSTS[0] else 'index-v2.archive.json' + filename = INDEX_FILES[repo] raw = os.path.join(directory, filename) path = raw + '.installable-v1' with _index_lock: @@ -143,8 +147,6 @@ def load_index(repo, cached_only=False): os.utime(tmp, ns=(refreshed, refreshed)) os.replace(tmp, path) _indexes[path] = (os.stat(path).st_mtime_ns, index) - if os.path.exists(raw): - os.remove(raw) return index finally: if os.path.exists(tmp):