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 <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 21:25:24 +10:00
1 parent 784a48f218
commit 08037ab3f5
6 files changed
+367 -8

No files matched your search

+25
View File
@@ -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.
+7
View File
@@ -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.
+54
View File
@@ -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 <b>fixture</b> &amp; 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
}
]
}
}
+143
View File
@@ -0,0 +1,143 @@
{
"repo": {
"name": {
"en-US": "Fixture"
}
},
"packages": {
"org.example.app": {
"metadata": {
"name": {
"en-US": "Example"
},
"summary": {
"en-US": "<p>Offline <b>fixture</b> &amp; music.</p>\n<p>One\t line.</p><script>hidden()</script>"
},
"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
}
}
}
}
}
+62
View File
@@ -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
+76 -8
View File
@@ -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