Add IzzyOnDroid to APK alternatives; keep the catalogue's index file

Reducing an index no longer deletes apk-catalog/data/index-v2.json, which
the catalogue build reads. Drop the measurement log from docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 14:30:15 +10:00
1 parent 32196b4260
commit 50405ccf88
6 files changed
+22 -88

No files matched your search

+1 -2
View File
@@ -1,7 +1,6 @@
.DS_Store .DS_Store
__pycache__/ __pycache__/
apk-catalog/data/cache/ apk-catalog/data/cache/
apk-catalog/data/index-v2.json* apk-catalog/data/index-v2*.json*
compat-db/.env.lakebed.server compat-db/.env.lakebed.server
compat-db/.lakebed/ compat-db/.lakebed/
apk-catalog/data/index-v2.archive.json*
-24
View File
@@ -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
+6 -53
View File
@@ -49,11 +49,12 @@ needs to be confirmed or started in the headset.
## When an app needs a newer Android ## When an app needs a newer Android
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses 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. an APK, it shows compatible versions from F-Droid's main and archive repos and
The refusal appears immediately; a separate request checks F-Droid while the IzzyOnDroid. It shows at most eight version names, newest first, preferring an
dialog shows “Checking F-Droid for older versions…”. It shows at most eight arm64-only build, and says how many compatible builds it found in total.
distinct version names, newest first, preferring an arm64-only build when Each index is reduced to its compatible builds once a day and cached (about
available. The count includes all compatible builds before release deduplication. 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 Choose **Install** to download a listed version, verify its SHA-256 against
the index, and install it as its own app. 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. whether the settings survive the app or its Lepton instance relaunching.
Lepton Development rebuilds its Android data on exit, so there they probably Lepton Development rebuilds its Android data on exit, so there they probably
don't. 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.
+7 -4
View File
@@ -28,7 +28,8 @@ class VersionsTest(unittest.TestCase):
os.mkdir(data) os.mkdir(data)
for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']), for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']),
build(3, abis=['arm64-v8a', 'x86_64']), build(2)]), 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: 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) 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)) 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): def test_failed_indexes_keep_search_links(self):
with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')): with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')):
result = versions.alternatives('org.example.app') result = versions.alternatives('org.example.app')
self.assertEqual(len(result['errors']), 2) self.assertEqual(len(result['errors']), 3)
self.assertEqual(len(result['links']), 5) self.assertEqual(len(result['links']), 5)
def test_no_compatible_versions(self): def test_no_compatible_versions(self):
@@ -79,7 +80,8 @@ class VersionsTest(unittest.TestCase):
self.assertEqual(set(index['org.example.app'][0]), self.assertEqual(set(index['org.example.app'][0]),
{'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'}) {'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'})
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json') 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')): with patch.object(frame_catalog.json, 'load', side_effect=AssertionError('reparsed')):
self.assertIs(frame_catalog.load_index(repo), index) self.assertIs(frame_catalog.load_index(repo), index)
path = raw + '.installable-v1' 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: 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) self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 9)
fetch.assert_called_once() fetch.assert_called_once()
self.assertFalse(os.path.exists(raw)) self.assertTrue(os.path.exists(raw))
def test_concurrent_requests_share_download(self): def test_concurrent_requests_share_download(self):
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json') 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) index = frame_catalog.load_index(repo)
path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1') path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1')
os.utime(path, ns=(1, 1)) 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 patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(b'{')):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
frame_catalog.load_index(repo) frame_catalog.load_index(repo)
+2 -1
View File
@@ -11,7 +11,8 @@ ANDROID = dict(enumerate([
'12L', '13', '14', '15', '16', '12L', '13', '14', '15', '16',
], 1)) ], 1))
REPOS = (('F-Droid', 'https://f-droid.org/repo/'), 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 ' 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.') 'arm64-v8a build (or no native code). Installable does not mean every feature works.')
+6 -4
View File
@@ -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). # 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 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')) 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() _lock = threading.Lock()
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None} _cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
_env = {} _env = {}
@@ -112,7 +116,7 @@ def load_index(repo, cached_only=False):
if repo not in APK_HOSTS: if repo not in APK_HOSTS:
raise ValueError('unexpected index URL') 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') 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) raw = os.path.join(directory, filename)
path = raw + '.installable-v1' path = raw + '.installable-v1'
with _index_lock: with _index_lock:
@@ -143,8 +147,6 @@ def load_index(repo, cached_only=False):
os.utime(tmp, ns=(refreshed, refreshed)) os.utime(tmp, ns=(refreshed, refreshed))
os.replace(tmp, path) os.replace(tmp, path)
_indexes[path] = (os.stat(path).st_mtime_ns, index) _indexes[path] = (os.stat(path).st_mtime_ns, index)
if os.path.exists(raw):
os.remove(raw)
return index return index
finally: finally:
if os.path.exists(tmp): if os.path.exists(tmp):