From 784a48f218abdfecbd3c98cdf230eb9d7f26c4fe Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:06:06 +1000 Subject: [PATCH 1/2] Add authenticated F-Droid user repositories and management CLI Verify pinned JAR/CMS signatures, v2 index hashes and APK downloads; support signed v1 fallback and persist TOFU identities. Reuse the catalogue reducer and document repository publishing with offline and live verification evidence. Co-Authored-By: GPT-6 Astra --- .claude/NOTES-user-repos.md | 55 +++ .claude/user-repos-proof.json | 50 +++ docs/apk-repos.md | 131 +++++++ tests/fixtures/fdroid/README.md | 14 + tests/fixtures/fdroid/entry.jar | Bin 0 -> 1834 bytes tests/fixtures/fdroid/example.apk | 1 + tests/fixtures/fdroid/fingerprint.txt | 1 + tests/fixtures/fdroid/index-v1.jar | Bin 0 -> 1917 bytes tests/fixtures/fdroid/index-v2.json | 1 + tests/fixtures/fdroid/izzy-entry.jar | Bin 0 -> 3300 bytes tests/test_fdroid_sources.py | 176 ++++++++++ ui/apk_sources/fdroid.py | 474 ++++++++++++++++++++++++++ 12 files changed, 903 insertions(+) create mode 100644 .claude/NOTES-user-repos.md create mode 100644 .claude/user-repos-proof.json create mode 100644 docs/apk-repos.md create mode 100644 tests/fixtures/fdroid/README.md create mode 100644 tests/fixtures/fdroid/entry.jar create mode 100644 tests/fixtures/fdroid/example.apk create mode 100644 tests/fixtures/fdroid/fingerprint.txt create mode 100644 tests/fixtures/fdroid/index-v1.jar create mode 100644 tests/fixtures/fdroid/index-v2.json create mode 100644 tests/fixtures/fdroid/izzy-entry.jar create mode 100644 tests/test_fdroid_sources.py create mode 100644 ui/apk_sources/fdroid.py diff --git a/.claude/NOTES-user-repos.md b/.claude/NOTES-user-repos.md new file mode 100644 index 0000000..6833762 --- /dev/null +++ b/.claude/NOTES-user-repos.md @@ -0,0 +1,55 @@ +# User repositories + +Scope: ui/apk_sources/fdroid.py, fixture tests and docs/apk-repos.md. No headset access. +No delegation or independent reviewer launched: task explicitly forbids delegation; +parent integrates and reviews. Existing catalogue API stays unchanged. + +Decisions: +- Support F-Droid signed v2 and v1, HTTPS only, RSA PKCS#1 CMS/JAR verification. +- Pin operator certificate fingerprints. Without a supplied fingerprint, verify + the complete signature chain of hashes on first use, then persist that signer. +- Reuse frame_catalog._IndexReader and _reduce_index; keep authenticated source + cache separate from legacy unauthenticated catalogue cache to avoid laundering trust. +- Sources list compatible builds (Android <=30, arm64 or no native code), as + existing catalogue reducer does. This is compatibility filtering, not a runtime guarantee. +- No Obtainium export import or new unsigned JSON format in this source. + +Research: downloaded F-Droid API/setup docs, Obtainium and SideQuest READMEs, +Izzy repo page and entry.jar via HTTPS. Izzy's published fingerprint matched +pure-Python CMS validation: 3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A. + +## Final verification + +All commands below ran in /Users/saphid/projects/steam-frame-userrepo on user-repos. + +- `python3 --version`: Python 3.9.6. +- `python3 -m unittest discover -s tests -p test_fdroid_sources.py`: initially + 13 tests, OK, exit 0. Added a cache-corruption test afterward. +- Final `python3 -m unittest discover -s tests`: 180 tests in 6.866s, OK, + exit 0 (includes 14 source tests and existing catalogue/version tests). +- `python3 ui/apk_sources/fdroid.py add 'https://apt.izzysoft.de/fdroid/repo?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A' --name 'IzzyOnDroid verification'`: exit 0; + saved fdroid-user-57e98c13877f14fdea65 with the published pin. +- `python3 ui/apk_sources/fdroid.py search fdroid-user-57e98c13877f14fdea65 'tinymusicplayer'`: + exit 0; com.martinmimigames.tinymusicplayer, version 1.3 / code 4, + GPL-3.0-only, 16,520 bytes. +- `python3 ui/apk_sources/fdroid.py download fdroid-user-57e98c13877f14fdea65 com.martinmimigames.tinymusicplayer`: + exit 0, verified true. Independent hashlib readback matched + d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a. +- Direct `_fetch` + `_jar` calls on F-Droid main/archive entry.jar: exit 0; + both matched the published 43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab pin. +- `.claude/user-repos-proof.json` retains live CLI results and APK readback. + Live APK remains in the per-user apk-sources cache; the added source remains + in the per-user apk-repos.json, as requested for the real add/search/download run. + +Not verified: headset installation/runtime, native UI/server integration (sibling +worker), real v1-only server (offline signed fixture covers fallback), executing +the fdroidserver publishing instructions, full F-Droid main/archive index/APK +downloads (their live signed entry jars were checked). No independent reviewer +was run because this task forbids delegation and assigns review to the parent. + +Known limits / follow-up questions: only one RSA-2048–8192 JAR signer supported; +no ECDSA/DSA/PSS or section-only SF signatures; no index timestamp rollback or +expiry policy, automated key rotation or cross-process settings-write locking. +The settings API serializes threads and publishes atomically. Should a later +change add explicit rollback policy and broader JAR algorithms? Parent may +choose UI wording for TOFU; this source already returns trust_on_first_use. diff --git a/.claude/user-repos-proof.json b/.claude/user-repos-proof.json new file mode 100644 index 0000000..71d737d --- /dev/null +++ b/.claude/user-repos-proof.json @@ -0,0 +1,50 @@ +{ + "add": { + "id": "fdroid-user-57e98c13877f14fdea65", + "kind": "fdroid", + "name": "IzzyOnDroid verification", + "url": "https://apt.izzysoft.de/fdroid/repo/", + "builtin": false, + "enabled": true, + "trust": "user", + "fingerprint": "3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a", + "trust_on_first_use": false + }, + "search": [ + { + "source": "fdroid-user-57e98c13877f14fdea65", + "id": "com.martinmimigames.tinymusicplayer", + "package": "com.martinmimigames.tinymusicplayer", + "name": "Tiny Music Player", + "summary": "Android 1.0+ minimal (", + "icon": "https://apt.izzysoft.de/fdroid/repo/com.martinmimigames.tinymusicplayer/en-US/icon.png", + "page": "https://martinmimigames.github.io/projects/tiny-music-player/index.html", + "vr": null, + "free": true, + "license": "GPL-3.0-only", + "downloadable": true, + "version": "1.3", + "version_code": 4, + "min_sdk": 1, + "abis": [], + "size": 16520, + "updated": "2023-02-04" + } + ], + "download": { + "apk": "/Users/saphid/Library/Caches/Frame Control/apk-sources/d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a.apk", + "obb": [], + "sha256": "d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a", + "verified": true + }, + "independent_readback": { + "size": 16520, + "sha256": "d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a" + }, + "python": "3.9.6", + "suite": "python3 -m unittest discover -s tests: 180 tests, 6.866s, OK, exit 0", + "builtins_entry_signatures": { + "fdroid": "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + "fdroid-archive": "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + } +} diff --git a/docs/apk-repos.md b/docs/apk-repos.md new file mode 100644 index 0000000..6e87879 --- /dev/null +++ b/docs/apk-repos.md @@ -0,0 +1,131 @@ +# APK repositories + +Frame Control supports **F-Droid-format repositories**, including F-Droid, +F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository +indexes are authenticated before their apps appear. Search lists builds with +Android API ≤30 and arm64-v8a or no native libraries, using the same streaming +reducer as the existing catalogue. This does not guarantee an app works in Lepton. + +## Formats considered + +| Format | Users and purpose | Support in this source | +|---|---|---| +| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes | +| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` | +| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index | +| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter | +| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source | +| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid | + +Research references (checked 2026-09-28): + +- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and + [repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/). +- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/) + and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/). +- [Droid-ify](https://github.com/Droid-ify/client) and + [Neo Store](https://github.com/NeoApplications/Neo-Store). +- [Obtainium](https://github.com/ImranR98/Obtainium), its + [configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/), + and [community app configurations](https://apps.obtainium.imranr.dev/). +- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest). + The absence of a specification in these materials is not proof that no + historical or private custom-feed format exists. + +## Add a repository in Frame Control + +From the Frame Control checkout, use its source-management CLI: + +```sh +python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps' +python3 ui/apk_sources/fdroid.py list +python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music' +python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app +python3 ui/apk_sources/fdroid.py remove SOURCE_ID +``` + +Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint` +can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…` +links are accepted and converted to HTTPS. Conflicting fingerprints are refused. +A URL must identify the repository directory, not its website or an index file. + +Adding fetches and validates the complete index **before saving** the source. +Without a fingerprint, Frame Control verifies the JAR signature and remembers +its signer: trust on first use (TOFU). This establishes continuity with the +first server response, not independent publisher identity. Obtain the published +fingerprint through a trusted channel when possible. Re-adding an existing URL +preserves its pin; changing it requires deliberately removing and re-adding it. + +The API for the search/server integration is in `ui/apk_sources/fdroid.py`: +`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`, +`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes +`sources`, `search`, `details`, and `download` from the shared source contract. +This change supplies the CLI and API; the integrated source-management UI is +separate work. Built-in sources can be disabled but cannot be removed. + +Settings and pins live in `frame_host.data_dir('apk-repos.json')` +(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS). +Authenticated reduced indexes and APKs live under +`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. +The existing catalogue's unverified index cache is never treated as authenticated. + +## Publish your own repository + +Only publish free APKs you own or have the developer's permission to distribute. +Do not publish paid app mirrors or bypass store licences. Check distribution +terms before adding someone else's repository; this module does not infer legal +permission from a signature or automatically audit a repository's terms. + +Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/) +and its documented Android/Java dependencies on the publishing machine, then: + +```sh +mkdir my-fdroid +cd my-fdroid +fdroid init +# Set repo_url in config.yml to https://example.org/fdroid/repo +# Also set repo_name and repo_description; keep the generated signing key safe. +cp /path/to/your-free-app.apk repo/ +fdroid update --create-metadata +# Review the generated metadata (name, summary, licence, source and website). +fdroid update +``` + +Serve the generated **repo directory** at that HTTPS URL, including APKs, +icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the +private signing keystore or configuration passwords. Configure fdroidserver's +`serverwebroot` and run `fdroid deploy` for managed publication, or copy the +public directory with your existing deployment tool. Publish the SHA-256 +repository certificate fingerprint displayed by fdroidserver in a link such as +`https://example.org/fdroid/repo?fingerprint=…`. + +Keep the repository signing key backed up: changing it breaks existing pins. +For updates, add the new APK, edit metadata as needed, run `fdroid update` and +publish again. Test the published URL with Frame Control's `add`, `search` and +`download` commands. The above publisher setup is documented from fdroidserver; +it was not executed as part of this implementation. + +## Verification and limits + +The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of +2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are +recognized. It checks the signer certificate pin, the signature over `.SF`, +the whole-manifest digest, and the manifest's digest of the JSON member. +ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are +rejected. Certificates are pinned identities, not validated as Web PKI chains. +HTTPS certificates are separately checked by Python's normal TLS validation. + +v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature, +fingerprint, index hash, TLS and server errors never trigger an unsigned +fallback. APKs are cached by SHA-256 and checked again before reuse. Here, +`verified: true` means the bytes match the signed repository's APK hash; it +is not an independent APK publisher-signature or runtime compatibility verdict. +There is no repository timestamp rollback/expiry policy or automated signing-key +rotation yet. An old correctly signed index can still validate. + +Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache +reuse, URL rejection and corruption of every signature/hash layer. On the Mac, +the real IzzyOnDroid repository was added with its published pin, searched for +Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256 +`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`. +No headset connection or installation was performed. diff --git a/tests/fixtures/fdroid/README.md b/tests/fixtures/fdroid/README.md new file mode 100644 index 0000000..01b7eb6 --- /dev/null +++ b/tests/fixtures/fdroid/README.md @@ -0,0 +1,14 @@ +# F-Droid verification fixtures + +`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs, +including CMS signed attributes. `fingerprint.txt` identifies their throwaway +certificate. Their JSON describes org.example.app; `example.apk` is deliberately +plain test data, not an installable app. The v2 index includes incompatible +Android-31 and x86-only versions to exercise the shared reducer. + +`izzy-entry.jar` was recorded from +https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate +fingerprint matches the operator's published fingerprint: +3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A. +It exercises an independent production JAR/CMS encoder without network access. +The index it references is not needed by this signature-only fixture test. diff --git a/tests/fixtures/fdroid/entry.jar b/tests/fixtures/fdroid/entry.jar new file mode 100644 index 0000000000000000000000000000000000000000..b73864ad0eddc09ab576a3dad6528fb1f46b7512 GIT binary patch literal 1834 zcmZ{ldpHwn9LHCq3rRwv&Phj1?2=`PIXToOo1vkda+!NBg{UNJF3E8$bu!m%R!v7T zluKEda!RsW8tLY85<7$~+SbL|na+7m<(%`r&-Zzs-}^q__x(J-KfbRQ7N}tW005Q% zfR`NuO!S74(dsHjt+`zs%EuYv=7EOcoITvoC~qGq4n2e8aow>HXtxd)VkO`S>+zd< zHV=)w7@5ZMaEw&S89Tw<9WboyUnk7pBUSF|I3VCtCYNk1@_>#?Mj>{XZ zEpQiN!5bl%-mk&FfbdjP%jWQbaF#(sKaaG`x=ZOI;#=ov<9QGnFk>xuntR+^D*>NK zsdF*&ubV ziLRBQgUuPiOv>~m^|VAB=NMT_Hiz!!$wj$}F_7_*rcER#J*MCeuQTmiXWsd=*B|1E zRq(0Qy-K~QJ1zlFfyDJB>LWxES2z1 zKv8Y(f;h~B>bJ;5EJvEN#-`01tg&x1pH$2VIT9H7y1SRlQiZWkqG2B!EF4|zZUsM@ zR8quEnuHW51+nplTvP)db6S4o=hP`9QIUJi9=oR&|UnM|J2cV8o^S5?pKvx1K+$Fv)d zR+zW=lVXIfoNy9sYuvVCP-WRiZfo7q$%1&qjjG$mEb+uKBTH@t0j}cM_9o>iQz^#w z_nKf8+fqq~pYkKqWmdm(>W5cdaz(M?-_~>=kq(ZVAqk5~@H)EkZO78ljuU&XCA)}w zCK8rm_+*c3hPp$(a!GbV!XHYxU!_^f!%VCn#^6BALnC`cczL4lx#O)-4t^1^r2S9j z<}`TGsnAhayz1`tt@pN@9VSb%`YH^x$z>yLZ#a>OGh|iHx^~sPG#NDIQ%UL+p{u2( zR(R~8gk)%RVq7dLm#_7k>55FE`2^dkCW0{eY;`|g`)S7d{H&G~9F#7S@;BAD(A3us^7@OPxochAq<&u(iq!9VYRa;p<~zI}d^KiiWvzihb|R&&wZnTC2Q K)ay5&sy_i1JRfWT literal 0 HcmV?d00001 diff --git a/tests/fixtures/fdroid/example.apk b/tests/fixtures/fdroid/example.apk new file mode 100644 index 0000000..e53f5b7 --- /dev/null +++ b/tests/fixtures/fdroid/example.apk @@ -0,0 +1 @@ +Fixture APK payload, deliberately not installable. diff --git a/tests/fixtures/fdroid/fingerprint.txt b/tests/fixtures/fdroid/fingerprint.txt new file mode 100644 index 0000000..494e9b0 --- /dev/null +++ b/tests/fixtures/fdroid/fingerprint.txt @@ -0,0 +1 @@ +0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a diff --git a/tests/fixtures/fdroid/index-v1.jar b/tests/fixtures/fdroid/index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..6ea58d2a306f7904f24be201d4d86587c52724cc GIT binary patch literal 1917 zcmZ{l4>TLr9>+siTNb5st+IcmHIY9J!X`RTw`fU%CMr{e_%l=+|62bdSZSvXb;#Dx zgs4J z>X`umfXx8CYp%gtkk+CTnv9}}ja?kt&mHRN?E%NRdwY7I34Sn~#{y0&!D67W9uNi! zmPzFe_>k#=1MNa!KYlk!tggeTmqN#EQ6;QJ>$C@F1#tijp{ zQP@hFDxS3eT};ADPV~G3Q3@e15pxHdPRA-jAYi7#XuGkBO9@ zb5yenjKpCdfyzE0Rc#}j?Z^Golu_*Mz5erp;|im> zqA0YU`@9dX8Zx~yxjmtk8sdmfcsb0ebVjjPfK&_MTi#Il6o1Du9|^B->6;{~?w!V! zqZGyEFXI>sfv>u@g^#qHCUfcYO2&oRsW{i@y39Q=f2q2+U}@UMJh^#ahMNgj)-Dxi zf8s8!$bS7cKBUHeChO>`$xMI|>N~p{Y#Rym##R=k<{PJ1Pm&Hh10vAtXTbq8z$2O4`DbNu<|3eG=Y!gy4(^ zeC^g=*8|RX$&Zw)^r>cjBGqjvq_IieTML&fuyh)wDkcbE}g9EhAnxCKv= zBPl6eF|yd28$9&&v7(|gHnIja%%yQ&J#V#3nQw}>=;xBo<9Y1EA1<2ML(Y0Z z3`#8u&GK4qbvycWyTqyrqo(->f85!;JGUDNBcf+WeXCob<_(Sr`&8E#ZkOJ)N5e76 z8Pgf2Ie?ZkA^MeLrv3fR$E7FF8}kew9#v z4FVb13E|zKo=aE+tB_PV=?N(*3(ov5kbgG^O&>aMJux+Z-MhSPWq>D1j1>{az^7TM zl9^hA?@7YfYi&U{2P>;y5khF`4{km^$8cU4QFNyxuU1a4#i9C{-YK((od(udY73utz>X!wQ6gpgx1Qc-oc&A~=3?;;rnaIcA^stHJk0Wf z+E_#kH|1_yYuTUMsS-%2{e~xH^zN|%!ym7TP#@pC45*|I5*I}seYXrSVFF{NS$T+ON`L^xw9lsf>ORQ}{ zw)k?MNz$I8dc_NCU6#4n?2vBtk>Uv_XXDsB$boPDOTR>Rrc=sBD6Qj$eiZ?m;#=bU0B3qH{p{&U^n5>12EmSke z&e*0gStk2Bmi+4b&$slS|9#JW?>X3U z1ppjQ002x!w+MG%Z^7Ly(BQu$N|`2A+Gox`rY3p6>y>fW;64M zMJ$C%AfC?(nBY@yiU`?%eC5C&o$vQ;2bnXc%Nu^Rp;>2G@J8=-8w`tr=U7Qs*bR12 zi~U;p&O&n|jd1@r0wL>%a&VzML!zaYb(89Y$JC(x%6PGdjaV~ZHN?58E<=mzubZZ) zmo1E<)SLR3VV$8XY7%Zr3huWE201F1i9o(edmvp6Jq}fag%d zE)M~*A6tM8^b`#wSv;VMG%^;Q(zjhZ6rh<2!igBTG39TGD%Z;}v_smi6IOu9r{<3j z%(upj%(2Z{VB3_d>hw^wuYxw)rjJV^yn?->T_B++6%?c2sK|oHggc|d_-n}-Nw`J9 zLKMfnX4KHK=UuK8!or9TWV;|puKEJz!#JmLvU?Ho64NxXlwE>kZ{{NqOKHt#B)AdJ zcE4b-jCqi~o;Ot*#BI$kjPB8DyjDiftCVSWR}dz`VX^sacy z)1R7Ml|LrjeYg=65_qF4mGxWsxydB?T4fU2*yTy4uhV*a&yzYcY-+lF5T&ikq~Y85 z!MXitQ=W^#zw+x_i{#93Gfj$v6H0piAj{!1_%didboueJL004`e7dddj8OqET$t)! z+$Tym{3?U_cE;^?PW8nCt0cN4dN!Wn$WJ5IV0@c&C)~Dl>$@5x2W@udAfsyh6+w9+ zO<8EQ`{eEsr`vn=C4Rb)LO0792&+U)xs+tq=j<29G8%{<)ui`O z+HCzOy@}jWpVh+7&cX!j3(W?z(!-1+HQYiGn1MU~i^xyTTIpIYK6BrdOVjSZvpLKP z?ed^>@9*yJ%EA6@e}+HXpN*+`zWS)`yN~SDkr`;gp)$IL+H!_k8b-3F+EzFya~Swc zD6wJs!N?n+{u4`aU+}fWKA3(9q08v@d}QY9O$u}Tg!N=w5+*w!MfLMMx18r$fFChUk++cE)Dh10m+>U zLLLdd(p&qViaxG>X@R~ptAC9YENj+o=A~w-CQlz$$E``mo`q^IMwMCMCU(u>pPvY1 zcCqTynk)E^{YVUaS8#ll`QHqik>?7ejvhDa$k_gep@}IpF4I(U=AZ znO_Udb22tD(3g2*YUf;9P*LELZTIYz%a{q$xF$9dguG^{PtOrHR%hAIQWiYxBwq7W z*|9qvHD$EEsy?gU^PWFz{aVsH4dS?@*YH>WQvhzIY#cd4)CUzx0}u|?Zhq&{VRcMZ z5C#Su%S9bp)2)5%RaOLDjt~u3Sr@bgutbhwXNPwC*M@=3L3RmkFErNM1c5N?JF@mQ zQqb-9%eJS)pkhp#XBL*{z4&EhBAe%OdhYaQIK@6q6@{|o`T>Kj*Y|Bm*cgFu@*!$x z#+(5p4Y>RT)$9NUNCuecA5B3=TLxB*=ugsn|AN-=nTxM^UCh%1GSLQg#f8s z5sbc#IWSxa!(IUD9jiU?D)wB;UobqM@)d6vca~YQ0L1DjCztAf&StbqCqb-3tnTXQ zDr#Fa2*ffx3b|Tslh`*MzI}?t2eX&F7*nL{^>)p7@I_%O3T#@`2$+UCphJi4(srKd zP;;2@`7>#cCew>f>|sMI(dbNb#-LiG^3%$ZlYTSQ^d_5&dV222j;3l-BMZ|-{_J)L z_0vJ$aD~%_!-Yac!cgOU_PS|$NW}bx_cCNl#x#FkhyO#cYnc`)AxSlS{ya{M;}y5D zn4034zZsLyRcYa2f8#J(gxjDRX`|McNlwv%Vi?>#mjBg*zSoA)cj&r1ZU4=ecTkg& zHB}0fI8l}k$9uq%=e2B#Av4aBPxO|iif4AL;=~HhDF;6Zex9M4k$YRaLB1vzjj?+S z->+=CQdA5pgux#Oz&;4smR46~gbRG>2G!qe2uVfYTbijDyFCe#5&3qJ`vPLmL-TNQ z<>A00m4OvD+i%P~c|f*rc|N-B$ZsI`w$#JNxxU5!z3YwxR6V5mYxQj>5_z9(qHCH9 zOb4K$_jUqR59qqR+R4=^iXIutHAYAbl|1sbFk%Qg6!m@`zw=-*Vt@3dqr3COP2Hq- z1Z(^9tq=T=B>Hx)W<;@T2egbQhp=% zt%#0Y=Qc(vN_C^K{KX5RD?6NL7&=sOYlAjcYbihcxR(kk?lPTdbq#ALIqucHxkE(l ztwMP*VJ}?I>!`Gb(r0GL6#oSoDt+?H0(MWh`AqQy;JwUqv)ZgAUVKsPnI`)RBFGNZ zv73d-!zQ+r-&eWINBNY~+k`b22niZxAx3z7Zf`inAurhNm$W?`zk0UhEQOms zK88)?4pq@vC?x3vMdO0)W`Yul!#L5S^2WpbWkcw)-QpN>X9VXV6#U26y%RA;C7$CK z9+vuLP6`{!yt>Du(u<>WwZ|qpvn*6*cV9N=^NM-iiUsbyr^f;eB)!<+JVr#W3d4Bg zlU{qVw+=!T2;TSX&A^VB9S70<>KBhs@&?Vz2M<;xDah!W=ro|hfvEDu*1#X+21MWs zx|<*bLRb|otswt#ayqr?VC3aP-4$-0dQ95V%GDFwk`e9Sb*o61Zv|k@YgLF3>NRhs zPrO#G7VL-{kB5Ioyy8|$Ib?_&!f^|gX7kp3I}^9mu8-$!%3mGbQ6iY^ND=z>B=Vfx z)LYwWvu32#sj@i^g76&PspAcvbN88^Ruxsy1;SrHrIUBS95CvjjiRH?esF_#Gs6!x zXAk=r^^S+{7yH{|ThyjARmv5Z3OE0dV2rYYB!vVlW@Au=D_u?;$;Ne*LZZ^hWcE03eb`@+I4vMF0Vm=X6JXl z8B&yL;QP8W!-2GRAux~iB{r^epooVPdeZ2NWH{-f=oz*Nt)gL)ixEc{m74lc>R8^m{)km&D=RFrzgp&eRF=)9p|lQLgR^puIu3FK5%IFR z0N9~063;$Si+1LZFTd*#li&fCeLN2AtzsZ0<7-A;xtG$y(sg4FqVRc?J!q<&>|!dI zC%+|HGl_oCsx3=J(y>~^z0s@psgS7mSk;t-&2CKyZ%Xc@O!kym%?!J%N@FG8X}-0K z#TBGL#;pEUH337Z2VSu3&<2Fl^Vw?+&%f=AC3wKZTfz~GtA=HRdlt7%?$;oD`2Rgw zLzx=uT#hCx^2mM)>c6yu0n|`eTgwzKYoNVufctzyUqD{omH=` zfNn{k;tivX_^uV^kN)dmS^TChYl&VReL4dGrcXo5dF=lS`=bPZt?GwA-QP0*7w%{N z|3?x1iTZgS9(@!3;`m>E^gH@@wedHac 1: + raise SourceError('only one fingerprint query parameter is supported') + pin = _fingerprint(fingerprint) if fingerprint else None + if pins: + linked = _fingerprint(pins[0]) + if pin and pin != linked: + raise SourceError('conflicting fingerprints') + pin = linked + return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin + + +def _child(base, name): + name = str(name).lstrip('/') + decoded = urllib.parse.unquote(name) + if not name or '\\' in decoded or any(x in ('.', '..') for x in decoded.split('/')): + raise SourceError('unsafe repository file name') + url = urllib.parse.urljoin(base, name) + if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment: + raise SourceError('repository file is outside its repository') + return url + + +class _HTTPSRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if urllib.parse.urlsplit(newurl).scheme != 'https': + raise SourceError('refusing non-HTTPS redirect') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def _fetch(url, path, maximum): + request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'}) + with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f: + total = 0 + while True: + chunk = r.read(1 << 20) + if not chunk: + break + total += len(chunk) + if total > maximum: + raise SourceError('repository file exceeds size limit') + f.write(chunk) + + +def _children(item): + return _der_parts(item[1]) + + +def _cms(data, content): + outer = _der_parts(data) + if len(outer) != 1: + raise ValueError('invalid CMS wrapper') + wrapper = _children(outer[0]) + if wrapper[0][1].hex() != '2a864886f70d010702': + raise ValueError('not CMS SignedData') + fields = _children(_children(wrapper[1])[0]) + certs = next(_children(f) for f in fields[3:] if f[0] == 0xa0) + signers = _children(fields[-1]) + if len(signers) != 1: + raise ValueError('exactly one repository signer required') + signer = _children(signers[0]) + sid = _children(signer[1]) + matching = [] + for cert in certs: + tbs = _children(_children(cert)[0]) + offset = 1 if tbs[0][0] == 0xa0 else 0 + if tbs[offset][1] == sid[1][1] and tbs[offset + 2][2] == sid[0][2]: + matching.append(cert[2]) + if len(matching) != 1: + raise ValueError('missing or ambiguous signer certificate') + cert = matching[0] + digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()] + at, signed = 3, content + if signer[at][0] == 0xa0: + attrs = {} + for attr in _children(signer[at]): + pair = _children(attr) + oid = pair[0][1].hex() + if oid in attrs: + raise ValueError('duplicate CMS attribute') + attrs[oid] = _children(pair[1]) + if attrs['2a864886f70d010904'][0][1] != hashlib.new(digest, content).digest(): + raise ValueError('CMS content digest mismatch') + if attrs['2a864886f70d010903'][0][1].hex() != '2a864886f70d010701': + raise ValueError('unexpected CMS content type') + signed = der(0x31, signer[at][1]) + at += 1 + algorithm = _children(signer[at])[0][1].hex() + allowed = {'sha1': '2a864886f70d010105', 'sha256': '2a864886f70d01010b', + 'sha384': '2a864886f70d01010c', 'sha512': '2a864886f70d01010d'} + if algorithm not in ('2a864886f70d010101', allowed[digest]): + raise ValueError('unsupported repository signature algorithm (RSA PKCS#1 required)') + n, e, _ = _cert_key(cert) + sig = signer[at + 1][1] + size = (n.bit_length() + 7) // 8 + if not 256 <= size <= 1024 or n % 2 != 1 or not 3 <= e <= 0xffffffff or e % 2 != 1 or len(sig) != size or int.from_bytes(sig, 'big') >= n: + raise ValueError('invalid RSA signature/key size') + value = bytes.fromhex(prefix) + hashlib.new(digest, signed).digest() + expected = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value + if pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') != expected: + raise ValueError('repository RSA signature mismatch') + return hashlib.sha256(cert).hexdigest() + + +def _sections(data): + sections = [] + for block in re.split(b'\r?\n\r?\n', data): + if not block: + continue + attrs = {} + for line in re.sub(b'\r?\n ', b'', block).splitlines(): + key, value = line.decode('utf-8').split(': ', 1) + key = key.lower() + if key in attrs: + raise ValueError('duplicate manifest attribute') + attrs[key] = value + sections.append(attrs) + return sections + + +def _digest_check(attrs, suffix, content): + for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')): + if label + suffix in attrs: + if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest(): + raise ValueError('JAR digest mismatch') + return + raise ValueError('missing supported JAR digest') + + +def _jar(path, member, pin): + try: + with zipfile.ZipFile(path) as z: + names = z.namelist() + if len(names) > 64 or len(names) != len(set(names)) or any( + i.file_size > (1024 * 1024 if i.filename.upper().startswith('META-INF/') else 256 * 1024 * 1024) + for i in z.infolist()): + raise ValueError('duplicate or oversized JAR member') + blocks = [n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith('.RSA')] + if len(blocks) != 1: + raise ValueError('exactly one RSA JAR signer required') + sf = z.read(blocks[0][:-4] + '.SF') + fingerprint = _cms(z.read(blocks[0]), sf) + if pin and fingerprint != pin: + raise ValueError('repository fingerprint mismatch') + manifest = z.read('META-INF/MANIFEST.MF') + _digest_check(_sections(sf)[0], '-digest-manifest', manifest) + entries = [s for s in _sections(manifest)[1:] if s.get('name') == member] + if len(entries) != 1: + raise ValueError('index is not uniquely signed') + content = z.read(member) + _digest_check(entries[0], '-digest', content) + return content, fingerprint + except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e: + raise SourceError('invalid signed repository: ' + str(e)) from e + + +def _storage(): + return frame_host.data_dir('apk-repos.json') + + +def _read(): + try: + settings = json.loads(_storage().read_text()) + if not isinstance(settings, dict) or not isinstance(settings.get('repos'), list) or not isinstance(settings.get('enabled'), dict): + raise ValueError('invalid settings structure') + return settings + except FileNotFoundError: + return {'repos': [], 'enabled': {}} + except (OSError, ValueError) as e: + raise SourceError('cannot read repository settings: ' + str(e)) from e + + +def _write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part') + try: + with os.fdopen(fd, 'w') as f: + json.dump(value, f, separators=(',', ':')) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +def user_repos(): + with _LOCK: + return _read()['repos'] + + +def sources(): + builtins = [('fdroid', 'F-Droid', 'https://f-droid.org/repo/', FDROID_PIN), + ('fdroid-archive', 'F-Droid archive', 'https://f-droid.org/archive/', FDROID_PIN), + ('izzyondroid', 'IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/', IZZY_PIN)] + settings = _read() + return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True, + enabled=settings['enabled'].get(i, True), trust='community') + for i, n, u, p in builtins] + settings['repos'] + + +def _text(value): + if isinstance(value, dict): + return value.get('en-US') or value.get('en') or next(iter(value.values()), '') + return value or '' + + +def _reduce(path, source): + compatible = _reduce_index(path) + result = {} + with open(path, encoding='utf-8') as f: + reader = _IndexReader(f) + for key in reader.members(): + if key != 'packages': + reader.value() + continue + for pkg in reader.members(): + item = reader.value() + if pkg not in compatible: + continue + meta = item.get('metadata', {}) + files = {v['file'].get('name'): v for v in item.get('versions', {}).values() + if isinstance(v, dict) and isinstance(v.get('file'), dict)} + versions = [] + for v in compatible[pkg]: + original = files[v['name']] + versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added')))) + versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True) + latest = versions[0] + icon = _text(meta.get('icon')) + result[pkg] = dict(source=source['id'], id=pkg, package=pkg, + name=_text(meta.get('name')) or pkg, summary=_text(meta.get('summary')), + icon=_child(source['url'], icon['name']) if isinstance(icon, dict) and icon.get('name') else None, + page=meta.get('webSite') or source['url'], vr=None, free=True, + license=meta.get('license'), downloadable=bool(latest.get('sha256')), + versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')}) + return result + + +def _date(value): + return time.strftime('%Y-%m-%d', time.gmtime(value / 1000)) if isinstance(value, (int, float)) else None + + +def _v1(content, path): + index = json.loads(content) + apps = {a['packageName']: a for a in index['apps']} + packages = {} + for pkg, builds in index['packages'].items(): + app = apps.get(pkg, {}) + localized = app.get('localized', {}) + en = localized.get('en-US') or next(iter(localized.values()), {}) + meta = {k: en.get(k) or app.get(k) for k in ('name', 'summary', 'license', 'webSite')} + versions = {} + for i, v in enumerate(builds): + versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'], + 'usesSdk': {'minSdkVersion': v.get('minSdkVersion', 1)}, 'nativecode': v.get('nativecode', [])}, + 'file': {'name': v['apkName'], 'sha256': v.get('hash') if v.get('hashType') == 'sha256' else None, + 'size': v.get('size')}, 'added': v.get('added')} + packages[pkg] = {'metadata': meta, 'versions': versions} + path.write_text(json.dumps({'packages': packages})) + + +def _load(source, force=False): + if not re.fullmatch(r'[a-z0-9-]+', source['id']): + raise SourceError('invalid source id') + _url(source['url'], source.get('fingerprint')) + cache = frame_host.cache_dir('apk-sources', source['id'] + '.json') + with _LOCK: + if not force and cache.exists() and time.time() - cache.stat().st_mtime < 86400: + try: + saved = json.loads(cache.read_text()) + if saved.get('fingerprint') == source.get('fingerprint') and saved.get('url') == source['url']: + return saved['apps'], saved['fingerprint'] + except (OSError, ValueError, KeyError, AttributeError): + pass + cache.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp: + jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json' + try: + _fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024) + except urllib.error.HTTPError as e: + if e.code not in (404, 410): + raise + _fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024) + content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint')) + _v1(content, raw) + else: + content, pin = _jar(jar, 'entry.json', source.get('fingerprint')) + entry = json.loads(content)['index'] + _fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024) + if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']): + raise SourceError('index SHA-256 or size mismatch') + apps = _reduce(raw, source) + _write(cache, {'url': source['url'], 'fingerprint': pin, 'apps': apps}) + return apps, pin + except SourceError: + raise + except (OSError, ValueError, KeyError, TypeError, IndexError) as e: + raise SourceError('cannot load repository: ' + str(e)) from e + + +def add_repo(url, fingerprint=None, name=None): + url, pin = _url(url, fingerprint) + with _LOCK: + settings = _read() + existing = next((s for s in settings['repos'] if s['url'] == url), None) + if existing: + if pin and pin != existing['fingerprint']: + raise SourceError('repository already has a different pinned fingerprint; remove it first') + pin = existing['fingerprint'] + source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND, + name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname, + url=url, builtin=False, enabled=True, trust='user', fingerprint=pin) + _, source['fingerprint'] = _load(source, force=True) + source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None + settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source] + _write(_storage(), settings) + return source + + +def remove_repo(source_id): + with _LOCK: + settings = _read() + if not any(s['id'] == source_id for s in settings['repos']): + raise SourceError('unknown user repository') + settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id] + _write(_storage(), settings) + + +def set_enabled(source_id, enabled): + if not isinstance(enabled, bool): + raise SourceError('enabled must be a boolean') + with _LOCK: + settings = _read() + source = next((s for s in sources() if s['id'] == source_id), None) + if not source: + raise SourceError('unknown repository') + if source['builtin']: + settings['enabled'][source_id] = enabled + else: + for s in settings['repos']: + if s['id'] == source_id: + s['enabled'] = enabled + _write(_storage(), settings) + + +def search(source, query, limit=50): + if not source.get('enabled', True): + return [] + apps, _ = _load(source) + words = query.casefold().split() + found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)] + found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold())) + return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]] + + +def details(source, entry_id): + if not source.get('enabled', True): + raise SourceError('repository is disabled') + apps, _ = _load(source) + if entry_id not in apps: + raise SourceError('app has no Lepton-compatible version in this repository') + return apps[entry_id] + + +def download(source, entry_id, version_code=None): + entry = details(source, entry_id) + version = next((v for v in entry['versions'] if version_code is None or str(v['version_code']) == str(version_code)), None) + if not version or not re.fullmatch('[0-9a-f]{64}', version.get('sha256') or ''): + raise SourceError('version is missing or has no SHA-256 digest') + sha = version['sha256'] + path = frame_host.cache_dir('apk-sources', sha + '.apk') + try: + if not path.exists() or _sha256(path) != sha: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part') + os.close(fd) + try: + _fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3) + if _sha256(tmp) != sha: + raise SourceError('APK SHA-256 mismatch; download discarded') + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + return {'apk': str(path), 'obb': [], 'sha256': sha, 'verified': True} + except OSError as e: + raise SourceError('cannot download APK: ' + str(e)) from e + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + add = sub.add_parser('add') + add.add_argument('url') + add.add_argument('--fingerprint') + add.add_argument('--name') + sub.add_parser('list') + remove = sub.add_parser('remove') + remove.add_argument('source') + for command in ('search', 'download'): + p = sub.add_parser(command) + p.add_argument('source') + p.add_argument('query' if command == 'search' else 'package') + args = parser.parse_args() + try: + if args.command == 'add': + result = add_repo(args.url, args.fingerprint, args.name) + elif args.command == 'list': + result = sources() + elif args.command == 'remove': + result = remove_repo(args.source) + else: + source = next((s for s in sources() if s['id'] == args.source), None) + if not source: + raise SourceError('unknown repository id; use list') + result = search(source, args.query) if args.command == 'search' else download(source, args.package) + print(json.dumps(result, indent=2)) + except SourceError as e: + parser.exit(1, 'error: ' + str(e) + '\n') + + +if __name__ == '__main__': + main() From 08037ab3f55577e36fc4a0c355a4ebc0bb5f6bb0 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:25:24 +1000 Subject: [PATCH 2/2] Expose F-Droid artwork and developer metadata for store entries Resolve localized v1/v2 artwork, retain six ordered screenshots, clean summaries and refresh older source caches. Add offline metadata and cache regression coverage. Co-Authored-By: GPT-6 Astra --- .claude/NOTES-user-repos.md | 25 +++++ tests/fixtures/fdroid/README.md | 7 ++ tests/fixtures/fdroid/artwork-v1.json | 54 ++++++++++ tests/fixtures/fdroid/artwork-v2.json | 143 ++++++++++++++++++++++++++ tests/test_fdroid_sources.py | 62 +++++++++++ ui/apk_sources/fdroid.py | 84 +++++++++++++-- 6 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/fdroid/artwork-v1.json create mode 100644 tests/fixtures/fdroid/artwork-v2.json diff --git a/.claude/NOTES-user-repos.md b/.claude/NOTES-user-repos.md index 6833762..b5952d0 100644 --- a/.claude/NOTES-user-repos.md +++ b/.claude/NOTES-user-repos.md @@ -53,3 +53,28 @@ expiry policy, automated key rotation or cross-process settings-write locking. The settings API serializes threads and publishes atomically. Should a later change add explicit rollback policy and broader JAR algorithms? Parent may choose UI wording for TOFU; this source already returns trust_on_first_use. + +## Artwork follow-up + +Added `images: {icon, banner, screenshots}`, matching top-level `icon`, +`developer` from authorName, and HTML/entity-aware one-line summaries. Artwork +prefers en-US per field, then the first populated locale. Phone screenshots +precede seven-inch screenshots, with at most six unique URLs. v2 supports both +`screenshots.phone/sevenInch` and the older phoneScreenshots/sevenInchScreenshots +field names. v1 localized filenames are resolved under package/locale, with +legacy top-level icons resolved under icons/. Missing art remains null/empty. + +No frame_catalog edit was necessary: this source already rereads raw metadata +after using the shared compatibility reducer. Incremented the source cache +schema so old entries refresh immediately rather than hiding artwork for a day. +Tests exercise v1/v2 metadata, cache round-trips and migration, locale fallback, +missing fields, legacy icon paths, screenshot bounds and summary cleanup. + +Follow-up verification (Python 3.9.6, branch user-repos): +- `python3 -m unittest discover -s tests -p test_fdroid_sources.py`: 19 tests, + 0.046s, OK, exit 0. +- `python3 -m unittest discover -s tests`: 185 tests, 5.081s, OK, exit 0. +- `git diff --check`: exit 0. +No live image fetch or redesigned store rendering was exercised; these remain +with the parent/UI integration. No independent review or delegation performed, +as explicitly requested. No new open implementation questions. diff --git a/tests/fixtures/fdroid/README.md b/tests/fixtures/fdroid/README.md index 01b7eb6..c50721a 100644 --- a/tests/fixtures/fdroid/README.md +++ b/tests/fixtures/fdroid/README.md @@ -12,3 +12,10 @@ fingerprint matches the operator's published fingerprint: 3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A. It exercises an independent production JAR/CMS encoder without network access. The index it references is not needed by this signature-only fixture test. + +`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures +based on the synthetic indexes above. They exercise en-US preference, per-field +locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap, +author names and HTML/multiline summaries. The signed integrity fixtures remain +unchanged; artwork tests feed these JSON files directly through the reducer and +then round-trip the resulting entries through the source cache. diff --git a/tests/fixtures/fdroid/artwork-v1.json b/tests/fixtures/fdroid/artwork-v1.json new file mode 100644 index 0000000..a156a0c --- /dev/null +++ b/tests/fixtures/fdroid/artwork-v1.json @@ -0,0 +1,54 @@ +{ + "apps": [ + { + "packageName": "org.example.app", + "name": "Example", + "license": "MIT", + "authorName": "Example Developer", + "summary": "Fallback summary", + "localized": { + "de": { + "name": "Beispiel", + "summary": "Deutsch", + "icon": "german.png", + "phoneScreenshots": [ + "german.png" + ] + }, + "en-US": { + "name": "Example", + "summary": "Offline fixture & music.\n One\t line.", + "icon": "icon.png", + "phoneScreenshots": [ + "1.png", + "2.png", + "3.png", + "4.png" + ] + }, + "fr": { + "featureGraphic": "featureGraphic.png", + "sevenInchScreenshots": [ + "1.png", + "2.png", + "3.png", + "4.png" + ] + } + } + } + ], + "packages": { + "org.example.app": [ + { + "versionName": "1", + "versionCode": 1, + "apkName": "example1.apk", + "hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "hashType": "sha256", + "size": 51, + "minSdkVersion": 21 + } + ] + } +} diff --git a/tests/fixtures/fdroid/artwork-v2.json b/tests/fixtures/fdroid/artwork-v2.json new file mode 100644 index 0000000..ed7c821 --- /dev/null +++ b/tests/fixtures/fdroid/artwork-v2.json @@ -0,0 +1,143 @@ +{ + "repo": { + "name": { + "en-US": "Fixture" + } + }, + "packages": { + "org.example.app": { + "metadata": { + "name": { + "en-US": "Example" + }, + "summary": { + "en-US": "

Offline fixture & music.

\n

One\t line.

" + }, + "license": "MIT", + "authorName": "Example Developer", + "icon": { + "de": { + "name": "/org.example.app/de/icon.png" + }, + "en-US": { + "name": "/org.example.app/en-US/icon.png" + } + }, + "featureGraphic": { + "fr": { + "name": "/org.example.app/fr/featureGraphic.png" + } + }, + "screenshots": { + "phone": { + "de": [ + { + "name": "/org.example.app/de/phoneScreenshots/1.png" + } + ], + "en-US": [ + { + "name": "/org.example.app/en-US/phoneScreenshots/1.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/2.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/3.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/4.png" + } + ] + }, + "sevenInch": { + "fr": [ + { + "name": "/org.example.app/fr/sevenInchScreenshots/1.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/2.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/3.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/4.png" + } + ] + } + } + }, + "versions": { + "1": { + "manifest": { + "versionName": "1", + "versionCode": 1, + "usesSdk": { + "minSdkVersion": 21 + }, + "nativecode": [] + }, + "file": { + "name": "/example1.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "2": { + "manifest": { + "versionName": "2", + "versionCode": 2, + "usesSdk": { + "minSdkVersion": 30 + }, + "nativecode": [ + "arm64-v8a" + ] + }, + "file": { + "name": "/example2.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "3": { + "manifest": { + "versionName": "3", + "versionCode": 3, + "usesSdk": { + "minSdkVersion": 31 + }, + "nativecode": [] + }, + "file": { + "name": "/example3.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "4": { + "manifest": { + "versionName": "4", + "versionCode": 4, + "usesSdk": { + "minSdkVersion": 21 + }, + "nativecode": [ + "x86_64" + ] + }, + "file": { + "name": "/example4.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + } + } + } + } +} diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py index 3e01e90..461b329 100644 --- a/tests/test_fdroid_sources.py +++ b/tests/test_fdroid_sources.py @@ -165,6 +165,68 @@ class Repositories(unittest.TestCase): self.assertEqual(len(fdroid.search(source, 'example')), 1) self.assertEqual(self.fetch_mock.call_count, 4) + def test_artwork_v1_and_v2_survives_source_cache(self): + source = self.add() + for version in ('v1', 'v2'): + with self.subTest(version=version): + raw = FIXTURES / ('artwork-' + version + '.json') + if version == 'v1': + normalized = self.root / 'normalized.json' + fdroid._v1(raw.read_bytes(), normalized) + raw = normalized + apps = fdroid._reduce(raw, source) + cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + fdroid._write(cache, {'version': fdroid.CACHE_VERSION, 'url': URL, + 'fingerprint': PIN, 'apps': apps}) + before = self.fetch_mock.call_count + result = fdroid.search(source, 'example offline')[0] + self.assertEqual(result['developer'], 'Example Developer') + self.assertEqual(result['summary'], 'Offline fixture & music. One line.') + self.assertEqual(result['icon'], URL + 'org.example.app/en-US/icon.png') + self.assertEqual(result['images'], { + 'icon': result['icon'], + 'banner': URL + 'org.example.app/fr/featureGraphic.png', + 'screenshots': [URL + 'org.example.app/en-US/phoneScreenshots/' + str(i) + '.png' for i in range(1, 5)] + + [URL + 'org.example.app/fr/sevenInchScreenshots/' + str(i) + '.png' for i in range(1, 3)]}) + self.assertEqual(fdroid.details(source, result['id'])['images'], result['images']) + self.assertEqual(self.fetch_mock.call_count, before) + + def test_missing_artwork_is_not_invented(self): + result = fdroid.search(self.add(), 'example')[0] + self.assertEqual(result['images'], {'icon': None, 'banner': None, 'screenshots': []}) + self.assertIsNone(result['icon']) + self.assertIsNone(result['developer']) + + def test_v1_legacy_icon_and_tablet_fallback(self): + index = json.loads((FIXTURES / 'artwork-v1.json').read_text()) + app = index['apps'][0] + app['localized'] = {'fr': {'sevenInchScreenshots': ['tablet.png']}} + app['icon'] = 'legacy.1.png' + raw = self.root / 'legacy.json' + fdroid._v1(json.dumps(index).encode(), raw) + result = fdroid._reduce(raw, {'id': 'test', 'url': URL})['org.example.app'] + self.assertEqual(result['icon'], URL + 'icons/legacy.1.png') + self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png']) + + def test_v2_legacy_screenshot_keys_and_limit(self): + meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]}, + 'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}} + images = fdroid._images(meta, URL) + self.assertEqual(images['screenshots'], [URL + 'phone/' + str(i) + '.png' for i in range(6)]) + meta.pop('phoneScreenshots') + self.assertEqual(fdroid._images(meta, URL)['screenshots'], [URL + 'tablet.png']) + + def test_old_cache_refreshes_for_artwork(self): + source = self.add() + path = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + saved = json.loads(path.read_text()) + saved.pop('version') + for app in saved['apps'].values(): + app.pop('images') + fdroid._write(path, saved) + self.assertIn('images', fdroid.search(source, 'example')[0]) + self.assertEqual(self.fetch_mock.call_count, 4) + def test_cached_index_does_not_cross_pins(self): source = self.add() source['fingerprint'] = '0' * 64 diff --git a/ui/apk_sources/fdroid.py b/ui/apk_sources/fdroid.py index 944ae2b..df62699 100644 --- a/ui/apk_sources/fdroid.py +++ b/ui/apk_sources/fdroid.py @@ -2,6 +2,7 @@ import argparse import base64 import hashlib +from html.parser import HTMLParser import json import os from pathlib import Path @@ -23,6 +24,7 @@ from frame_apk_sign import _der_parts, _cert_key, der from frame_catalog import _IndexReader, _reduce_index, _sha256 KIND = 'fdroid' +CACHE_VERSION = 2 _LOCK = threading.RLock() # Published by the repository operators; a user repository without a pin uses TOFU. FDROID_PIN = '43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab' @@ -250,10 +252,61 @@ def sources(): def _text(value): if isinstance(value, dict): - return value.get('en-US') or value.get('en') or next(iter(value.values()), '') + return value.get('en-US') or next((v for v in value.values() if v), '') return value or '' +class _PlainText(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts, self.hidden = [], 0 + + def handle_starttag(self, tag, attrs): + if tag in ('script', 'style'): + self.hidden += 1 + elif tag in ('br', 'p', 'div', 'li'): + self.parts.append(' ') + + def handle_endtag(self, tag): + if tag in ('script', 'style'): + self.hidden = max(0, self.hidden - 1) + elif tag in ('p', 'div', 'li'): + self.parts.append(' ') + + def handle_data(self, data): + if not self.hidden: + self.parts.append(data) + + +def _summary(value): + parser = _PlainText() + parser.feed(_text(value)) + parser.close() + return ' '.join(''.join(parser.parts).split()) + + +def _images(meta, base): + def url(file): + name = file.get('name') if isinstance(file, dict) else file + return _child(base, name) if isinstance(name, str) and name else None + + icon = url(_text(meta.get('icon'))) + banner = url(_text(meta.get('featureGraphic'))) + screenshots = [] + groups = meta.get('screenshots') or {} + for device, legacy in (('phone', 'phoneScreenshots'), ('sevenInch', 'sevenInchScreenshots')): + files = _text(groups.get(device)) or _text(meta.get(legacy)) or [] + for file in files if isinstance(files, list) else []: + image = url(file) + if image and image not in screenshots: + screenshots.append(image) + if len(screenshots) == 6: + break + if len(screenshots) == 6: + break + return {'icon': icon, 'banner': banner, 'screenshots': screenshots} + + def _reduce(path, source): compatible = _reduce_index(path) result = {} @@ -276,10 +329,10 @@ def _reduce(path, source): versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added')))) versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True) latest = versions[0] - icon = _text(meta.get('icon')) + images = _images(meta, source['url']) result[pkg] = dict(source=source['id'], id=pkg, package=pkg, - name=_text(meta.get('name')) or pkg, summary=_text(meta.get('summary')), - icon=_child(source['url'], icon['name']) if isinstance(icon, dict) and icon.get('name') else None, + name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')), + icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None, page=meta.get('webSite') or source['url'], vr=None, free=True, license=meta.get('license'), downloadable=bool(latest.get('sha256')), versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')}) @@ -297,8 +350,22 @@ def _v1(content, path): for pkg, builds in index['packages'].items(): app = apps.get(pkg, {}) localized = app.get('localized', {}) - en = localized.get('en-US') or next(iter(localized.values()), {}) - meta = {k: en.get(k) or app.get(k) for k in ('name', 'summary', 'license', 'webSite')} + meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k) + for k in ('name', 'summary', 'license', 'webSite', 'authorName')} + for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'): + images = {} + for locale, fields in localized.items(): + value = fields.get(field) + if not value: + continue + prefix = pkg + '/' + locale + '/' + if field.endswith('Screenshots'): + images[locale] = [{'name': prefix + field + '/' + name} for name in value[:6]] + else: + images[locale] = {'name': prefix + value} + meta[field] = images + if not meta['icon'] and app.get('icon'): + meta['icon'] = {'en-US': {'name': 'icons/' + app['icon']}} versions = {} for i, v in enumerate(builds): versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'], @@ -318,7 +385,8 @@ def _load(source, force=False): if not force and cache.exists() and time.time() - cache.stat().st_mtime < 86400: try: saved = json.loads(cache.read_text()) - if saved.get('fingerprint') == source.get('fingerprint') and saved.get('url') == source['url']: + if (saved.get('version') == CACHE_VERSION and saved.get('fingerprint') == source.get('fingerprint') + and saved.get('url') == source['url']): return saved['apps'], saved['fingerprint'] except (OSError, ValueError, KeyError, AttributeError): pass @@ -341,7 +409,7 @@ def _load(source, force=False): if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']): raise SourceError('index SHA-256 or size mismatch') apps = _reduce(raw, source) - _write(cache, {'url': source['url'], 'fingerprint': pin, 'apps': apps}) + _write(cache, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps}) return apps, pin except SourceError: raise